Skip to content
Draft
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
40 changes: 35 additions & 5 deletions src/index_503/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
4 changes: 2 additions & 2 deletions src/index_503/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
58 changes: 57 additions & 1 deletion tests/test_cache.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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