Migrations

Upgrade guides for breaking changes to the cachetta PyPI package. Each entry is required to include all five sections below. Write “None.” if a section truly has no content, so reviewers know it was considered.

v0.8 → v0.9

Summary

clear and aclear are no longer plain aliases of invalidate/ainvalidate. They are now an expiry-aware sweep of whatever the instance’s path resolves to: a folder is walked recursively (directories are kept), a single file is checked in place, and a missing path is a no-op. Without force, only entries that are no longer servable are deleted — age ≥ duration, plus stale_duration when configured, so entries inside the stale-while-revalidate window are kept. The keyword-only force=True skips the walk and removes the resolved path wholesale, folder and all; the folder is re-created on the next write. Both methods still return None. The change exists so hashed/foldered caches can be cleaned of dead entries without knowing every arg-set ever used (#110). invalidate/ainvalidate are unchanged.

Required changes

| Before | After | |——–|——-| | cache.clear() (expecting unconditional delete) | cache.clear(force=True) or cache.invalidate() | | await cache.aclear() (expecting unconditional delete) | await cache.aclear(force=True) or await cache.ainvalidate() | | cache.clear(user_id=123) (delete one entry unconditionally) | cache.clear(user_id=123, force=True) or cache.invalidate(user_id=123) |

Deprecations removed

None.

Behavior changes without code changes

  • clear()/aclear() without force now keep entries younger than duration + stale_duration instead of deleting the resolved file unconditionally.
  • clear()/aclear() on a folder now sweep the folder’s files recursively; previously they attempted to os.unlink the folder itself and raised IsADirectoryError.
  • clear(force=True) removes the resolved folder itself, not just its contents. Code holding a path to that folder (or watching it) sees it disappear until the next write re-creates it.
  • A path-resolving keyword argument named force can no longer be forwarded to a callable path via clear/aclearforce is consumed as the sweep flag. Use invalidate (unchanged) or rename the callable’s parameter if you need a literal force kwarg.

Verification

  • cache.clear() on a cache whose file was written moments ago must leave the file in place.
  • cache.clear(force=True) on the same cache must delete it, and a subsequent write_cache must succeed (the folder is re-created).

v0.7 → v0.8

Summary

The skip_self option has been removed. It existed so that decorating an instance/class method wouldn’t fold the receiver (self/cls) into the cache key, but it worked by blindly stripping the first positional argument whenever set — with no check that the call was actually a method, so setting it on a plain function silently discarded a real argument. Receiver exclusion is now automatic: the decorator detects method binding via the descriptor protocol and strips the receiver from key/path resolution while still passing it to the wrapped function. Plain functions keep all of their positional arguments. Because detection is automatic and correct, the flag is no longer needed and passing it raises TypeError.

In the same release, the in-memory LRU layer has been removed entirely. It arrived in the initial code import with no design rationale, the library’s sole known consumer never used it, and issues #79/#82/#83 tracked broken behavior in it (stale entries surviving invalidation, inconsistent eviction under concurrent access, and values served past duration expiry). Rather than patch a layer nobody asked for, it’s removed outright — see tracking issue #98. Cachetta remains a disk-backed cache; callers wanting an in-memory layer should add their own (e.g. functools.lru_cache composed around the decorator, or a process-local dict) since the right eviction/TTL semantics are application-specific.

Also in this release, InvalidPathError and the ..-segment check in _get_path have been removed (#85). The check raised InvalidPathError claiming “path traversal detected,” but it only ever matched a literal .. path segment — an absolute path or a symlink pointing outside the intended directory passed through with no error at all. That gave a false sense of protection without providing one. Cachetta’s sole consumer treats cache paths as developer-authored configuration, not attacker- controlled input, so the maintainer decided to remove the check rather than build it out into something that actually confines paths. path (literal or callable) is now used exactly as given, with no validation.

Required changes

| Before | After | |——–|——-| | @Cachetta(path=fn, skip_self=True)
def method(self, x): ... | @Cachetta(path=fn)
def method(self, x): ...
receiver excluded automatically | | cache = Cachetta(path=..., skip_self=True) | cache = Cachetta(path=...) | | cache.copy(skip_self=True) | cache.copy() | | cache = Cachetta(path=..., lru_size=100) | cache = Cachetta(path=...)
no in-memory layer; every read hits disk | | from cachetta import InvalidPathError
try: cache._get_path()
except InvalidPathError: ... | Remove the import and the except clause — the exception no longer exists and _get_path no longer raises for path shape. |

Deprecations removed

  • The skip_self field on Cachetta (and the skip_self= keyword to its constructor, copy, and per-decoration overrides). It was never deprecated with a warning; it is removed outright.
  • The lru_size field on Cachetta, the internal _lru/_lru_lock state, and the _lru_get/_lru_set helpers. Also removed outright, with no deprecation warning period.
  • The InvalidPathError exception class and its export from cachetta.exceptions/cachetta.__init__. Removed outright — it was never deprecated. Any except InvalidPathError clause is now dead code and will fail at import time.

Behavior changes without code changes

  • Decorating an instance/class method now excludes the receiver from the cache key by default. Code that previously relied on the old default (skip_self=False) to key on the receiver — e.g. a callable path or hashed=True whose key intentionally varied per instance — will now share cache entries across instances for equal arguments. This was almost never intentional (object identity is not stable across runs), but if you need per-instance partitioning, key on an explicit instance attribute via a callable path.
  • Every cache read now goes to disk, even for calls that would previously have been served from the in-memory LRU. Throughput on tight read loops against the same key will drop to disk I/O speed; layer your own in-memory cache in front of Cachetta if that matters for your workload.
  • _get_path no longer raises for a path (literal or callable-returned) containing .. segments, an absolute path, or a symlink — it resolves and returns whatever path says, unchanged. Code that relied on the traversal check to reject bad configuration will now silently write to wherever the path points; validate path yourself before construction if that matters for your use case.

Verification

  • After upgrading, confirm the receiver no longer reaches a callable path:
    from cachetta import Cachetta
    
    seen = []
    cache = Cachetta(path=lambda x: seen.append(x) or f"/tmp/{x}.dat")
    
    class Svc:
        @cache
        def get(self, x):
            return x
    
    Svc().get("a")
    assert seen == ["a"]  # the path callable saw "a", not (self, "a")
    

    Pre-upgrade (without skip_self=True) this raised TypeError because the lambda received self as an extra positional argument.

  • Confirm lru_size is gone:
    from cachetta import Cachetta
    try:
        Cachetta(path="/tmp/x.dat", lru_size=10)
        raise AssertionError("expected TypeError")
    except TypeError:
        pass  # expected: lru_size is no longer a valid keyword
    
  • Confirm InvalidPathError is gone and traversal-shaped paths are used as given:
    from pathlib import Path
    from cachetta import Cachetta
    
    cache = Cachetta(path="foo/../bar.dat")
    assert cache._get_path() == Path("foo/../bar.dat")  # no longer raises
    
    try:
        from cachetta import InvalidPathError
        raise AssertionError("expected ImportError")
    except ImportError:
        pass  # expected: InvalidPathError no longer exists
    

v0.6 → v0.7

Summary

The implicit “sibling-hash” behavior has been removed. When path was a str or Path, cachetta used to silently rewrite the filename into a {stem}-{hash}{ext} sibling whenever the wrapped function received arguments. That default collided across functions that happened to share a stem in the same folder, left a dead stem prefix on every file name, and gave callers no clean way to organize multiple cache kinds under one root. path=str|Path now means exactly that: cachetta writes to the literal path you gave it, regardless of arguments.

Required changes

| Before | After | |——–|——-| | @Cachetta(path="cache/llm.pkl")
def call(prompt): ...
wrote to cache/llm-<hash>.pkl | @Cachetta(path=lambda prompt: f"cache/llm/{prompt}.pkl")
def call(prompt): ...
explicit per-arg path callable | | cache = Cachetta(path="cache.json")
cache.invalidate("a")
removed cache-<hash>.json | cache = Cachetta(path=lambda x: f"cache/{x}.json")
cache.invalidate("a")
removes the path you actually wrote |

If a single literal file is what you actually want, no change is needed — that’s the new default.

Deprecations removed

  • The implicit {stem}-{hash}{ext} sibling rewrite in Cachetta._get_path when path is a str or Path.

Behavior changes without code changes

  • Cachetta(path="cache.json") decorating a function with arguments now writes every call to cache.json (last write wins) instead of fanning out into per-args sibling files. Existing on-disk cache-<hash>.json files written by older versions are no longer read; delete or migrate them by hand if you need their contents.

Verification

  • After upgrading, run:
    from cachetta import Cachetta
    cache = Cachetta(path="/tmp/cachetta_check.pkl")
    assert str(cache._get_path("anything")) == "/tmp/cachetta_check.pkl"
    

    The assertion should succeed; pre-upgrade it produced a path of the form /tmp/cachetta_check-<hash>.pkl.