From e54f05cfd1782d236b2edc2d67199ea0c274b117 Mon Sep 17 00:00:00 2001 From: Rodion Kazennov Date: Mon, 12 Oct 2026 11:41:07 +0000 Subject: [PATCH] fix(jobs): write and read job.json as UTF-8 The default name of an imported model contains U+00B7, which CP932 cannot encode, so on Japanese Windows the import failed while writing job.json. Reading falls back to the platform encoding for job.json files written by earlier versions. Fixes #131 --- lelab/jobs.py | 18 ++++++++++++++---- tests/test_jobs.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/lelab/jobs.py b/lelab/jobs.py index 10810a16c..6a4e9ea60 100644 --- a/lelab/jobs.py +++ b/lelab/jobs.py @@ -636,6 +636,16 @@ def _job_meta_path(output_root: Path, job_id: str) -> Path: return _job_dir(output_root, job_id) / "job.json" +def _read_job_meta(path: Path) -> dict: + """job.json is UTF-8. Earlier versions wrote it in the platform's default + encoding (CP932 on Japanese Windows), so fall back to that for old files.""" + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + text = path.read_text() + return json.loads(text) + + class JobAlreadyRunningError(Exception): """Raised when start() is called while another local job is running.""" @@ -739,13 +749,13 @@ def _rewrite_output_dir_in_meta(self, job_dir: Path) -> None: if not meta.is_file(): return try: - data = json.loads(meta.read_text()) + data = _read_job_meta(meta) except Exception as exc: logger.warning("Migration: could not parse %s: %s", meta, exc) return data["output_dir"] = str(job_dir / "run") tmp = meta.with_suffix(meta.suffix + ".tmp") - tmp.write_text(json.dumps(data, indent=2)) + tmp.write_text(json.dumps(data, indent=2), encoding="utf-8") os.replace(tmp, meta) def set_on_change(self, callback: Callable[[], None] | None) -> None: @@ -1115,7 +1125,7 @@ def _load_from_disk(self) -> None: if not meta.exists(): continue try: - data = json.loads(meta.read_text()) + data = _read_job_meta(meta) record = JobRecord.model_validate(data) except Exception as exc: logger.warning("Skipping malformed job.json at %s: %s", meta, exc) @@ -1272,7 +1282,7 @@ def _write_meta(self, record: JobRecord) -> None: # Atomic write so a crash mid-write never strands a half-written file # that would skip the job on next _load_from_disk. tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(record.model_dump_json(indent=2)) + tmp.write_text(record.model_dump_json(indent=2), encoding="utf-8") os.replace(tmp, path) diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 10384b1f5..1db2384a9 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -271,3 +271,46 @@ def list_repo_files(self, repo_id, repo_type): assert rec.output_dir == "" cks = reg.list_checkpoints(rec.id) assert [c.ref for c in cks] == ["user/some-model@root"] + + +def _default_to_cp932(monkeypatch) -> None: + """Make Path.read_text/write_text default to CP932, as on Japanese Windows.""" + from pathlib import Path + + real_write_text, real_read_text = Path.write_text, Path.read_text + + def write_text(self, data, encoding=None, *args, **kwargs): + return real_write_text(self, data, encoding or "cp932", *args, **kwargs) + + def read_text(self, encoding=None, *args, **kwargs): + return real_read_text(self, encoding or "cp932", *args, **kwargs) + + monkeypatch.setattr(Path, "write_text", write_text) + monkeypatch.setattr(Path, "read_text", read_text) + + +def test_job_json_survives_a_cp932_default_encoding(monkeypatch, tmp_path) -> None: + """CP932 cannot encode the "·" in the default name of an imported model (#131).""" + from lelab.jobs import JobRegistry + + model = tmp_path / "model" + _make_pretrained(model) + _default_to_cp932(monkeypatch) + + rec = JobRegistry(tmp_path / "root").register_imported(str(model)) + + assert "·" in rec.name + assert JobRegistry(tmp_path / "root").get(rec.id).name == rec.name + + +def test_cp932_job_json_written_before_the_fix_still_loads(monkeypatch, tmp_path) -> None: + from lelab.jobs import JobRegistry + + model = tmp_path / "model" + _make_pretrained(model) + rec = JobRegistry(tmp_path / "root").register_imported(str(model), name="日本語のモデル") + meta = next((tmp_path / "root").rglob("job.json")) + meta.write_bytes(meta.read_text(encoding="utf-8").encode("cp932")) + _default_to_cp932(monkeypatch) + + assert JobRegistry(tmp_path / "root").get(rec.id).name == "日本語のモデル"