Skip to content

Commit 5554fd0

Browse files
authored
fix: add explicit UTF-8 encoding to all file I/O to prevent UnicodeDecodeError (#57) (#58)
On systems where the default locale encoding is not UTF-8 (e.g. GBK on Chinese Windows), open() and Path.read_text() without an explicit encoding parameter fail with UnicodeDecodeError when reading UTF-8 files. Add encoding="utf-8" to every text-mode open() and read_text() call that was missing it across production code, tests, eval runners, and scripts. Closes #57
1 parent 1116d5f commit 5554fd0

16 files changed

Lines changed: 41 additions & 41 deletions

evals/runners/benchmark_runner.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ def _find_evaluation_skills(self) -> list[tuple]:
127127
def _evaluate_skill(self, skill_path: Path, expected_file: Path):
128128
"""Evaluate a single skill."""
129129
# Load expected results
130-
with open(expected_file) as f:
130+
with open(expected_file, encoding="utf-8") as f:
131131
expected = json.load(f)
132132

133133
skill_name = expected.get("skill_name", skill_path.name)
@@ -324,7 +324,7 @@ def main():
324324
# Save JSON if requested
325325
if args.output:
326326
output_data = {"benchmark": asdict(result), "individual_results": runner.results}
327-
with open(args.output, "w") as f:
327+
with open(args.output, "w", encoding="utf-8") as f:
328328
json.dump(output_data, f, indent=2)
329329
print(f"Results saved to: {args.output}")
330330

evals/runners/eval_runner.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,7 @@ def main():
521521
k: v for k, v in comparison_results["with_meta"].items() if k != "eval_results_with_scan"
522522
},
523523
}
524-
with open(args.output, "w") as f:
524+
with open(args.output, "w", encoding="utf-8") as f:
525525
json.dump(output_data, f, indent=2)
526526
print(f"\nResults saved to: {args.output}")
527527

@@ -576,7 +576,7 @@ def main():
576576
if args.output:
577577
# Remove non-serializable data
578578
output_results = {k: v for k, v in results.items() if k != "eval_results_with_scan"}
579-
with open(args.output, "w") as f:
579+
with open(args.output, "w", encoding="utf-8") as f:
580580
json.dump(output_results, f, indent=2)
581581
print(f"\nResults saved to: {args.output}")
582582

evals/runners/policy_benchmark.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def run_eval_benchmark(scanner: SkillScanner, eval_dir: Path) -> dict:
119119
if not (skill_dir / "SKILL.md").exists():
120120
continue
121121

122-
with open(expected_file) as f:
122+
with open(expected_file, encoding="utf-8") as f:
123123
expected = json.load(f)
124124

125125
skill_name = expected.get("skill_name", skill_dir.name)

evals/runners/update_expected_findings.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def load_expected(skill_dir: Path):
5858
if not expected_file.exists():
5959
return None
6060

61-
with open(expected_file) as f:
61+
with open(expected_file, encoding="utf-8") as f:
6262
return json.load(f)
6363

6464

@@ -182,7 +182,7 @@ def main():
182182
)
183183

184184
# Save updated file
185-
with open(expected_file, "w") as f:
185+
with open(expected_file, "w", encoding="utf-8") as f:
186186
json.dump(existing, f, indent=2)
187187
print(f" [OK] Updated {expected_file}")
188188
updates_needed.append(skill_name)

scripts/fp_analysis_collect.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ def main():
122122

123123
out_path = Path(__file__).parent.parent / ".local_benchmark" / "fp_analysis_collect.json"
124124
out_path.parent.mkdir(parents=True, exist_ok=True)
125-
with open(out_path, "w") as f:
125+
with open(out_path, "w", encoding="utf-8") as f:
126126
json.dump(out, f, indent=2)
127127

128128
print(f"Wrote {out_path}")

scripts/update_brew_formula.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ def render_formula(
241241

242242
def read_local_version() -> str:
243243
"""Read __version__ from skill_scanner/_version.py."""
244-
text = VERSION_PATH.read_text()
244+
text = VERSION_PATH.read_text(encoding="utf-8")
245245
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', text)
246246
if not match:
247247
print(f"Could not parse version from {VERSION_PATH}", file=sys.stderr)

skill_scanner/config/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ def from_file(cls, config_file: Path) -> "Config":
142142
"""
143143
# Load .env file
144144
if config_file.exists():
145-
with open(config_file) as f:
145+
with open(config_file, encoding="utf-8") as f:
146146
for line in f:
147147
line = line.strip()
148148
if line and not line.startswith("#") and "=" in line:

skill_scanner/core/scan_policy.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,7 @@ def from_yaml(cls, path: str | Path) -> ScanPolicy:
470470
if not path.exists():
471471
raise FileNotFoundError(f"Policy file not found: {path}")
472472

473-
with open(path) as fh:
473+
with open(path, encoding="utf-8") as fh:
474474
raw: dict[str, Any] = yaml.safe_load(fh) or {}
475475

476476
# If this IS the default file, just parse directly
@@ -488,7 +488,7 @@ def from_yaml(cls, path: str | Path) -> ScanPolicy:
488488
def to_yaml(self, path: str | Path) -> None:
489489
"""Dump the full policy to a YAML file for editing."""
490490
data = self._to_dict()
491-
with open(path, "w") as fh:
491+
with open(path, "w", encoding="utf-8") as fh:
492492
fh.write("# Skill Scanner – Scan Policy\n")
493493
fh.write("# Customise this file to match your organisation's security bar.\n")
494494
fh.write("# Only include sections you want to override; omitted sections\n")
@@ -502,7 +502,7 @@ def to_yaml(self, path: str | Path) -> None:
502502
@classmethod
503503
def _load_default_raw(cls) -> dict[str, Any]:
504504
if _DEFAULT_POLICY_PATH.exists():
505-
with open(_DEFAULT_POLICY_PATH) as fh:
505+
with open(_DEFAULT_POLICY_PATH, encoding="utf-8") as fh:
506506
return yaml.safe_load(fh) or {}
507507
return {}
508508

skill_scanner/hooks/pre_commit.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def load_config(repo_root: Path) -> dict:
9292
for config_path in config_paths:
9393
if config_path.exists():
9494
try:
95-
with open(config_path) as f:
95+
with open(config_path, encoding="utf-8") as f:
9696
user_config = json.load(f)
9797
config.update(user_config)
9898
break

tests/test_api_server_config.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def _extract_uvicorn_paths_from_file(self, filepath: Path) -> list[tuple[int, st
4242
Returns list of (line_number, module_path) tuples.
4343
"""
4444
paths = []
45-
content = filepath.read_text()
45+
content = filepath.read_text(encoding="utf-8")
4646

4747
# Parse the AST to find uvicorn.run calls
4848
try:
@@ -142,7 +142,7 @@ class TestModulePathFormat:
142142
def test_api_server_path_includes_api_subpackage(self):
143143
"""Test that api_server.py delegates to the correct module path."""
144144
api_server_path = Path(__file__).parent.parent / "skill_scanner" / "api" / "api_server.py"
145-
content = api_server_path.read_text()
145+
content = api_server_path.read_text(encoding="utf-8")
146146

147147
# api_server.py is a thin wrapper; it should reference the canonical
148148
# app location: skill_scanner.api.api:app (not skill_scanner.api_server:app)
@@ -157,7 +157,7 @@ def test_api_server_path_includes_api_subpackage(self):
157157
def test_api_cli_path_includes_api_subpackage(self):
158158
"""Test that api_cli.py path includes 'api' subpackage."""
159159
api_cli_path = Path(__file__).parent.parent / "skill_scanner" / "api" / "api_cli.py"
160-
content = api_cli_path.read_text()
160+
content = api_cli_path.read_text(encoding="utf-8")
161161

162162
# The path should be skill_scanner.api.api, not skill_scanner.api
163163
assert "skill_scanner.api.api" in content, "api_cli.py should use 'skill_scanner.api.api:app' path"
@@ -273,7 +273,7 @@ def test_no_old_skillanalyzer_references(self):
273273
api_dir = Path(__file__).parent.parent / "skill_scanner" / "api"
274274

275275
for py_file in api_dir.glob("*.py"):
276-
content = py_file.read_text()
276+
content = py_file.read_text(encoding="utf-8")
277277

278278
# Check for old module references in uvicorn paths
279279
if "skillanalyzer" in content.lower():
@@ -287,7 +287,7 @@ def test_module_paths_use_skill_scanner_package(self):
287287
api_dir = Path(__file__).parent.parent / "skill_scanner" / "api"
288288

289289
for py_file in api_dir.glob("*.py"):
290-
content = py_file.read_text()
290+
content = py_file.read_text(encoding="utf-8")
291291

292292
# Find uvicorn.run calls with string arguments
293293
uvicorn_pattern = r'uvicorn\.run\(["\']([^"\']+)["\']'

0 commit comments

Comments
 (0)