Skip to main content

xtr-cache-contracts

The caching contract, and nothing else — so a library that caches installs nothing else.

python 3.11+ asyncio core dependencies: 2 typed license MIT

Why?

A library that caches should not decide where values go. It takes a CacheInterface, hands it a key and the function that computes the value, and leaves memory, files or a server to the application that wires it.

That needs the contract, not a backend. This package is that dependency, reduced to what the seam is made of:

  • 🔁 CacheInterface — fetch-or-compute in one call, and delete.
  • 🏷️ TagAwareCacheInterface — drop every value carrying a tag, whatever its key.
  • 🗃️ CacheItemPoolInterface — the items underneath: hits told apart from misses, several keys per round trip, batched writes.
  • 📁 NamespacedPoolInterface — a view of a pool confined to a sub-namespace.
  • 🧩 CacheMixin — CacheInterface for free on any pool, with early recomputation.
  • 🪶 Two small dependencies — typing-extensions, for @override on 3.11, and xtr-clock, which has none of its own.
from xtr_cache_contracts import CacheInterface, ItemInterface


class Profiles:
    def __init__(self, cache: CacheInterface) -> None:
        self._cache = cache

    async def get(self, user_id: int) -> Profile:
        async def load(item: ItemInterface) -> Profile:
            item.expires_after(3600)
            return await fetch_profile(user_id)

        return await self._cache.get(f"profile.{user_id}", load)

Install

uv add xtr-cache-contracts

Requires Python 3.11+.

Who installs what

Depends on
A library that caches xtr-cache-contracts at runtime.
An application xtr-cache, which implements this contract with adapters and wires pools from configuration.

xtr-cache re-exports every symbol here rather than redefining it, so xtr_cache.CacheInterface is xtr_cache_contracts.CacheInterface. That identity is what lets a container register a pool under the interface and a library, which never imported xtr-cache, receive it.

Fetch-or-compute

class CacheInterface(Protocol):
    async def get(
        self,
        key: str,
        callback: Callback[T],
        /,
        *,
        beta: float | None = None,
        metadata: Metadata | None = None,
    ) -> T: ...

    async def delete(self, key: str, /) -> bool: ...

On a miss, callback is awaited with the item for key; what it returns is saved and returned. On a hit, the stored value comes back as the type callback returns — keep one key for one type.

Checking for a value, computing it and saving it is three steps with a race between each. Handing the cache the computation instead lets it decide when to run it, which is what makes stampede protection possible: computing a missing key once for every caller waiting on it, or refreshing a value just before it expires.

  • The callback is any async def taking the item, or an object with an async def __call__. It sets the value's lifetime and tags through the item. If it raises, nothing is stored and the error reaches the caller unchanged.
  • beta controls early recomputation. A hit whose metadata says when it expires and how long it took to compute may be recomputed before it expires, with a chance that grows as expiry nears and as beta grows. 0 disables it, math.inf forces recomputation now, and None leaves the choice to the implementation (1.0 in CacheMixin).
  • metadata is a dict the cache fills: expiry (Unix timestamp), ctime (milliseconds the value took to compute), tags, and save_failed when a computed value could not be stored.
from xtr_cache_contracts import Metadata

metadata: Metadata = {}
report = await cache.get("report.daily", build_report, metadata=metadata)
if metadata.get("save_failed"):
    ...

Items and pools

CacheItemPoolInterface is the level below: for code that needs a hit told apart from a miss, several keys in one round trip, or writes batched until commit() — and the level a backend implements.

item = await pool.get_item("rate.42")
if not item.is_hit():
    await pool.save(item.set(0).expires_after(60))

items = await pool.get_items(["a", "b", "c"])  # every key, hit or miss, in the order asked
await pool.save_deferred(item)  # queued ...
await pool.commit()  # ... stored

An item is returned for every key, found or not, so None is a value like any other. Changing an item reaches the backend only once it is saved. A pool refuses, with False, an item of a kind it does not store.

The rules:

  • Keys are non-empty strings without any of {}()/\@: (RESERVED_CHARACTERS). Letters, digits, _ and . up to 64 characters work everywhere; a pool may accept more. Tags follow the same rules.
  • A backend failure never raises. The call returns False, or reads as a miss, and the implementation logs it: code that caches keeps working when the cache does not. What raises is a mistake in the calling code — an invalid key, a tag on an item whose pool cannot store tags.
  • Lifetimes are set on the item: expires_after(seconds or timedelta) or expires_at(datetime). None falls back to the pool's default; a lifetime of zero or less removes the key when the item is saved.
  • Deferred items are committed by commit(), or before their key is read from the same pool. Nothing commits them when the pool is discarded.

Tags and namespaces

A tag-aware cache drops values by tag rather than by key. Tag a value when computing it:

async def load_invoice(item: ItemInterface) -> Invoice:
    item.tag([f"customer.{customer_id}", "invoices"])
    return await fetch_invoice(invoice_id)


await cache.get(f"invoice.{invoice_id}", load_invoice)
await cache.invalidate_tags([f"customer.{customer_id}"])  # every invoice of that customer

A namespaced pool hands out a view of itself whose keys live under a sub-namespace, so a group of keys can be cleared together: pool.with_sub_namespace("tenant42"). The original pool is left as it was. Tags ignore sub-namespaces.

Implementing a pool

Implement CacheItemPoolInterface and derive from CacheMixin to get get() and delete() built on your get_item(), save() and delete_item():

from xtr_cache_contracts import CacheItemPoolInterface, CacheMixin


class MyPool(CacheItemPoolInterface, CacheMixin): ...

CacheMixin computes a miss, saves it, reports a failed save in metadata, and recomputes a hit early when its metadata allows. It reads the time from the clock in force (xtr_clock.now()), so mock_time() or a MockClock installed by a test freezes it too. It does not make concurrent misses on one key compute once — that needs a lock or a shared in-flight computation, and is the implementation's to add by overriding get().

What is not here

Everything that stores or acts on values: the item class, adapters for memory, files and Redis, serialisation, stampede locking, chaining, and the bundle. All of that is xtr-cache.

There is no simple key-value interface (get(key, default) / set(key, value, ttl)): it cannot tell a cached None from a miss, and checking then reading is a race. Fetch-or-compute covers the common case, and the item pool covers the rest.

Errors

Error Raised when
CacheError Never directly — the base every caching error derives from, xtr-cache's included, such as the one tag() raises on an item whose pool cannot store tags
InvalidArgumentError A key, a tag, a namespace or beta is invalid (also a ValueError); what is wrong is in reason

Development

Developed in the python-xtr monorepo, under packages/xtr-cache-contracts; run the commands below from there. The python-xtr-cache-contracts repository is a read-only copy, so send issues and pull requests to the monorepo.

uv sync
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest

License

MIT — see LICENSE.

Metadata

Release files for xtr-cache-contracts 4.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for xtr-cache-contracts 4.1.0
File Size Uploaded
xtr_cache_contracts-4.1.0.tar.gz 16.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for xtr-cache-contracts 4.1.0
File Interpreter ABI Platform
xtr_cache_contracts-4.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 36.3 kB

Release files / xtr_cache_contracts-4.1.0.tar.gz

Download URL xtr_cache_contracts-4.1.0.tar.gz
Size 16.0 kB
Tags Source
SHA-256 checksum
How to use checksums
30300a66731b3c0f124d23153d5c7a19cd1fe6812a3cd4d611e0a8153ab94a28
BLAKE2b-256 checksum
How to use checksums
06b66da6c641387eccd9cc823c19a0255a3ba0c122876c623fb66cf181edf030
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.

Transparency log

Release files / xtr_cache_contracts-4.1.0-py3-none-any.whl

Download URL xtr_cache_contracts-4.1.0-py3-none-any.whl
Size 20.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
fb7bf15b9dca8bf470e956506218df2e3c1d3404d3893561ad909f7a6c0a6dbf
BLAKE2b-256 checksum
How to use checksums
c075231703ee0a6b5d1d3afcf85a9dc5cdbf1de00e9ea90367076bb54b6b01ee
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

4.1.0 This release

2 release files

4.0.4

2 release files

4.0.3

2 release files

4.0.2

2 release files

3.0.0

2 release files

2.0.0

2 release files

1.5.0

2 release files

1.4.0

2 release files

1.3.0

2 release files

1.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page