From 4dfcc8bde26e76ad72bdfe19fd933de3b9a3bb51 Mon Sep 17 00:00:00 2001 From: aiolibsbot Date: Sun, 17 May 2026 17:08:26 +0000 Subject: [PATCH 1/2] fix: recover from corrupt cache.json + write compact sorted cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A truncated or hand-edited cache.json currently crashes the whole index build with a JSONDecodeError. Treat unreadable / non-object cache files as empty instead — the next run rebuilds them by re-hashing wheels, which is slower but never fatal. Also write the cache with sort_keys=True and compact separators so the on-disk bytes are stable across runs (diffs reflect real changes, not dict ordering) and a touch smaller for large indexes. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/index_503/cache.py | 40 +++++++++++++++++++++++++---- tests/test_cache.py | 58 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/src/index_503/cache.py b/src/index_503/cache.py index b6e4644..196dac3 100644 --- a/src/index_503/cache.py +++ b/src/index_503/cache.py @@ -19,14 +19,44 @@ def __init__(self, target_path: Path) -> None: self.cache: dict[str, dict[str, Any]] = {} def load(self) -> None: - """Load the cache from a file.""" - if self.cache_file.exists(): - self.cache = load_json_file(self.cache_file) + """Load the cache from a file. + + A corrupt or unreadable cache is treated as empty — the next run + will rebuild it by re-hashing every wheel. This is slower, but + avoids aborting the whole index build because of a stale or + truncated cache file. + """ + if not self.cache_file.exists(): + return + try: + loaded = load_json_file(self.cache_file) + except (OSError, ValueError) as err: + _LOGGER.warning( + "Cache %s is unreadable (%s); rebuilding from scratch", + self.cache_file, + err, + ) + return + if not isinstance(loaded, dict): + _LOGGER.warning( + "Cache %s is not a JSON object; rebuilding from scratch", + self.cache_file, + ) + return + self.cache = loaded def write_to_new(self, target: Path) -> None: - """Write the cache to a new file.""" + """Write the cache to a new file. + + Sorted keys + compact separators keep the on-disk file small and + produce a stable byte representation, so diffs between two runs + reflect real changes rather than dict ordering. + """ new_cache_file = target.joinpath(CACHE_FILE) - write_utf8_file(new_cache_file, json.dumps(self.cache)) + write_utf8_file( + new_cache_file, + json.dumps(self.cache, sort_keys=True, separators=(",", ":")), + ) def remove_stale_keys(self, all_wheel_files: set[str]) -> None: """Remove any wheel file names that no longer exist.""" diff --git a/tests/test_cache.py b/tests/test_cache.py index 85edd6a..741099f 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,6 +1,9 @@ +import json from pathlib import Path -from index_503.cache import IndexCache +import pytest + +from index_503.cache import CACHE_FILE, IndexCache def test_cache(tmp_path: Path) -> None: @@ -27,3 +30,56 @@ def test_cache(tmp_path: Path) -> None: cache3 = IndexCache(target_path2) cache3.load() assert cache3.cache == {"foo": {"bar": "baz"}} + + +def test_cache_write_is_sorted_and_compact(tmp_path: Path) -> None: + """Cache is written with sorted keys and no whitespace. + + A stable byte representation makes diffs between runs reflect real + changes rather than dict ordering. + """ + source_path = tmp_path.joinpath("source") + source_path.mkdir() + target_path = tmp_path.joinpath("target") + target_path.mkdir() + + cache = IndexCache(source_path) + cache.cache["zzz"] = {"b": 2, "a": 1} + cache.cache["aaa"] = {"d": 4, "c": 3} + cache.write_to_new(target_path) + + on_disk = target_path.joinpath(CACHE_FILE).read_text() + # sorted, no whitespace, no trailing newline -> compact + deterministic + assert on_disk == '{"aaa":{"c":3,"d":4},"zzz":{"a":1,"b":2}}' + + +def test_cache_load_corrupt_json_recovers( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A corrupt cache file is treated as empty, not fatal.""" + target_path = tmp_path.joinpath("target") + target_path.mkdir() + target_path.joinpath(CACHE_FILE).write_text("{not json") + + cache = IndexCache(target_path) + with caplog.at_level("WARNING"): + cache.load() + + assert cache.cache == {} + assert "unreadable" in caplog.text + + +def test_cache_load_non_object_recovers( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A valid-JSON but non-object cache file is treated as empty.""" + target_path = tmp_path.joinpath("target") + target_path.mkdir() + target_path.joinpath(CACHE_FILE).write_text(json.dumps([1, 2, 3])) + + cache = IndexCache(target_path) + with caplog.at_level("WARNING"): + cache.load() + + assert cache.cache == {} + assert "not a JSON object" in caplog.text From 0f8583b92e55f3b08963fd951808959f05d74561 Mon Sep 17 00:00:00 2001 From: aiolibsbot Date: Sun, 17 May 2026 18:27:33 +0000 Subject: [PATCH 2/2] fix: loosen load_json_file return type to Any so cache validation isn't unreachable Co-Authored-By: Claude Opus 4.7 (1M context) --- src/index_503/util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index_503/util.py b/src/index_503/util.py index ceb0a53..1059992 100644 --- a/src/index_503/util.py +++ b/src/index_503/util.py @@ -33,8 +33,8 @@ def get_sha256_hash(filename: Path) -> str: return sha256(bytes).hexdigest() -def load_json_file(filename: Path) -> dict[str, dict[str, Any]]: - """Get a json file.""" +def load_json_file(filename: Path) -> Any: + """Get a json file. Returns whatever the JSON decodes to.""" with filename.open("rb") as f: bytes = f.read() # read entire file as bytes return json.loads(bytes)