Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions lelab/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)


Expand Down
43 changes: 43 additions & 0 deletions tests/test_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "日本語のモデル"