Source code for voxcity.utils.cache

"""On-disk cache for downloaded geospatial data.

Downloads are keyed by the wrapped function's fully-qualified name plus ALL of
its bound arguments (positional and keyword, matched against the function's
signature so equivalent calls hash the same regardless of calling
convention). Parameters named ``output_dir`` or ``download_dir`` are excluded
from the key, since they only control where files are staged on disk and do
not affect the downloaded data itself -- two calls that differ only in
``output_dir`` should hit the same cache entry. Results are stored as pickles
under a per-user cache directory. The cache is best-effort: any read/write
failure falls back to a fresh download. Layout:

    <cache_dir>/downloads/<key>.pkl        # pickled return value
    <cache_dir>/downloads/<key>.meta.json  # source, args, created timestamp

The cache directory resolves, in order: the VOXCITY_CACHE_DIR environment
variable, else ``~/.voxcity/cache``.

Entries never expire on their own: a cached rectangle is reused until the
caller passes ``force_refresh=True`` or ``clear_download_cache()`` is run.
Empty results (None or a zero-length GeoDataFrame/collection) are NOT cached,
so a transient upstream outage cannot poison future runs.
"""

import functools
import hashlib
import inspect
import json
import os
import pickle
import time
from pathlib import Path

from .logging import get_logger

_logger = get_logger(__name__)

# Parameters that only control where files are staged on disk, not what data
# is downloaded -- excluded from the cache key so a different output_dir
# still hits the same cache entry.
_EXCLUDED_PARAMS = {"output_dir", "download_dir"}


[docs] def get_cache_dir() -> Path: """Return (creating if needed) the directory downloads are cached under.""" root = os.environ.get("VOXCITY_CACHE_DIR") base = Path(root) if root else Path.home() / ".voxcity" / "cache" d = base / "downloads" d.mkdir(parents=True, exist_ok=True) return d
def _cache_key(func, args, kwargs) -> str: """Build a cache key from all bound arguments of ``func``. Binds ``args``/``kwargs`` against ``func``'s signature so the key is independent of whether a given argument was passed positionally or by keyword, then drops directory-only parameters (see ``_EXCLUDED_PARAMS``) before hashing. Falls back to hashing the raw args/kwargs if signature binding fails for any reason. """ try: sig = inspect.signature(func) bound = sig.bind(*args, **kwargs) bound.apply_defaults() key_args = { name: value for name, value in bound.arguments.items() if name not in _EXCLUDED_PARAMS } except TypeError: key_args = {"args": args, "kwargs": kwargs} payload = json.dumps(key_args, default=str, sort_keys=True) digest_input = f"{func.__qualname__}:{payload}".encode() return hashlib.blake2b(digest_input, digest_size=16).hexdigest() def _is_empty_result(value) -> bool: """True when a download produced no usable data (never worth caching).""" if value is None: return True try: return len(value) == 0 except TypeError: return False
[docs] def cached_download(func): """Cache a downloader's return value on disk, keyed by its arguments. The wrapped function gains two keyword-only controls (consumed here, not forwarded to ``func``): ``use_download_cache`` (default True) and ``force_refresh`` (default False). """ @functools.wraps(func) def wrapper(*args, **kwargs): use_cache = kwargs.pop("use_download_cache", True) force_refresh = kwargs.pop("force_refresh", False) if not use_cache: return func(*args, **kwargs) key = _cache_key(func, args, kwargs) path = get_cache_dir() / f"{key}.pkl" if path.exists() and not force_refresh: try: with open(path, "rb") as f: value = pickle.load(f) _logger.info("Using cached %s result (%s)", func.__name__, path.name) return value except Exception as e: # corrupt cache: fall through to refresh _logger.info("Cache read failed for %s (%s); re-downloading", func.__name__, e) value = func(*args, **kwargs) if _is_empty_result(value): # A stressed server can return a valid-but-empty payload; caching # it would silently pin "no data" for this rectangle forever. _logger.info("Not caching empty %s result", func.__name__) return value try: # Write via a temp file + atomic replace so a concurrent reader or # an interrupted run can never observe a torn pickle. tmp_path = path.with_suffix(".pkl.tmp") with open(tmp_path, "wb") as f: pickle.dump(value, f) os.replace(tmp_path, path) meta = {"function": func.__qualname__, "created": time.time()} with open(path.with_suffix(".meta.json"), "w") as f: json.dump(meta, f) except Exception as e: # cache write is best-effort _logger.info("Cache write failed for %s: %s", func.__name__, e) return value return wrapper
[docs] def clear_download_cache() -> int: """Delete all cached downloads. Returns the number of files removed.""" n = 0 for p in get_cache_dir().glob("*"): try: p.unlink() n += 1 except OSError: pass return n