From cc5ef517c4033c0c478c876103275b42689c8779 Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Wed, 4 Nov 2026 00:49:57 -0500 Subject: [PATCH] fix: keep the modules cache when the downloaded JSON is malformed _download_modules_json opened the cache with mode w before decoding the response, so an HTTP 200 with a malformed body truncated the cache and the CLI could not start. Decode first and write the replacement to a temporary file that replaces the cache only once complete. Co-Authored-By: Claude Sonnet 5.5 --- lean/models/__init__.py | 12 +++++- tests/models/test_download_modules_json.py | 44 ++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 tests/models/test_download_modules_json.py diff --git a/lean/models/__init__.py b/lean/models/__init__.py index 115e20e5..885e59b6 100644 --- a/lean/models/__init__.py +++ b/lean/models/__init__.py @@ -31,9 +31,17 @@ def _download_modules_json() -> None: res = get(url, timeout=5) if res.ok: from json import dump + # Decode the response and write a temporary file first, so a malformed response + # or a failed write leaves the existing file untouched. + data = res.json() file_path.parent.mkdir(parents=True, exist_ok=True) - with open(file_path, 'w', encoding='utf-8') as f: - dump(res.json(), f, ensure_ascii=False, indent=4) + temporary_file_path = file_path.with_name(file_path.name + ".tmp") + try: + with open(temporary_file_path, 'w', encoding='utf-8') as f: + dump(data, f, ensure_ascii=False, indent=4) + temporary_file_path.replace(file_path) + finally: + temporary_file_path.unlink(missing_ok=True) else: res.raise_for_status() diff --git a/tests/models/test_download_modules_json.py b/tests/models/test_download_modules_json.py new file mode 100644 index 00000000..0376edbb --- /dev/null +++ b/tests/models/test_download_modules_json.py @@ -0,0 +1,44 @@ +import json +from pathlib import Path + +import pytest +from requests.exceptions import JSONDecodeError +from responses import RequestsMock + +import lean.models as models + + +def _write_cache(path: Path) -> str: + content = json.dumps({"modules": {"SavedModule": {"version": "1.0"}}}) + path.write_text(content, encoding="utf-8") + return content + + +def test_download_modules_json_keeps_cache_when_response_is_not_json(requests_mock: RequestsMock, + monkeypatch: pytest.MonkeyPatch) -> None: + cache = Path.cwd() / "cache" / "modules.json" + cache.parent.mkdir() + content = _write_cache(cache) + monkeypatch.setattr(models, "file_path", cache) + requests_mock.add(requests_mock.GET, models.url, body="not json", status=200) + + with pytest.raises(JSONDecodeError): + models._download_modules_json() + + assert cache.read_text(encoding="utf-8") == content + assert [p.name for p in cache.parent.iterdir()] == ["modules.json"] + + +def test_download_modules_json_replaces_cache_with_valid_response(requests_mock: RequestsMock, + monkeypatch: pytest.MonkeyPatch) -> None: + cache = Path.cwd() / "cache" / "modules.json" + cache.parent.mkdir() + _write_cache(cache) + monkeypatch.setattr(models, "file_path", cache) + new_modules = {"modules": {"NewModule": {"version": "2.0"}}} + requests_mock.add(requests_mock.GET, models.url, json=new_modules, status=200) + + models._download_modules_json() + + assert json.loads(cache.read_text(encoding="utf-8")) == new_modules + assert [p.name for p in cache.parent.iterdir()] == ["modules.json"]