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/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) 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