Skip to main content

emboss

On-Disk Input-keyed Cache — disk-backed memoization with pydantic-aware encoding.

Version: 0.12.1

pip install emboss              # core — zero runtime dependencies (stdlib only)
pip install emboss[pydantic]    # + pydantic v2 BaseModel support
pip install emboss[diskcache]   # + use diskcache.Cache as a backend (now optional)

Why

functools.lru_cache is per-process. A disk cache survives invocations but pickling values as-is breaks the moment your cached return type is a pydantic BaseModel defined in __main__ (the new process can't unpickle __main__.MyModel). emboss fixes that by detecting BaseModel return annotations and converting to/from plain dicts at the cache boundary.

It ships its own dependency-free backends (SqliteCache, FileCache) — diskcache is no longer required, but stays a drop-in option.

Plus: a None-aware sentinel so functions returning None actually cache instead of re-running every call.

Quick start

from emboss import cached, SqliteCache

cache = SqliteCache("/tmp/my-cache")

@cached(cache)
def fetch(url: str) -> dict:
    import requests
    return requests.get(url).json()

fetch("https://api.example.com/users/1")  # network
fetch("https://api.example.com/users/1")  # cached, no network

Default cache directory

Passing a cache is optional. With @cached() (no cache argument), emboss creates a default SqliteCache at the directory named by the EMBOSS_CACHE_DIR environment variable; if unset, it falls back to a fresh temporary directory.

EMBOSS_CACHE_DIR=.data/cache python my_script.py

The cache location never affects keying — keys are function source + arguments either way.

Pydantic BaseModel returns

emboss reads the function's return type annotation. If it sees a BaseModel, list[BaseModel], dict[str, BaseModel], or BaseModel | None, it serialises via model.model_dump() before pickling and rehydrates via Model.model_validate(...) on read. The cached value on disk is a plain dict — round-trips cleanly across process boundaries, even for models defined in __main__.

from pydantic import BaseModel

class User(BaseModel):
    id: int
    name: str

@cached(cache)
def get_user(uid: int) -> User | None:
    ...

@cached(cache)
def list_users() -> list[User]:
    ...

@cached(cache)
def users_by_id() -> dict[str, User]:
    ...

Functions returning non-BaseModel types continue to pickle as-is — fully backward-compatible.

None caching

@cached(cache)
def lookup(query: str) -> str | None:
    return external_api(query)

lookup("missing")  # returns None, cached
lookup("missing")  # returns cached None, no re-run

The previous behaviour (skip-cache-on-None) is replaced by a _MISSING sentinel internally so None is a valid cached value.

Cache key

Arguments are converted via safe_jsonable_encoder (recursive JSON-friendly conversion handling sets, bytes, dates, Path, BaseModel, and objects with __dict__), then hashed with the function source + name. Re-decorating the same function body → same key; changing the function body → new key (transparent cache invalidation on code change). The source hash is AST-canonical and docstring-agnostic: formatting, comments, and docstrings — which document behaviour rather than implement it — never invalidate the cache; any other source change does. One exception: a function whose source reads __doc__ (docstring-as-prompt, doctest-style dispatch) implements behaviour with its docstrings, so it keeps the docstring-sensitive keying and a docstring edit still invalidates. Detection is syntactic — an indirect read like inspect.getdoc(f) isn't caught; pin such functions with unsafe_manual_key.

Custom or strict encoder (default=)

safe_jsonable_encoder mirrors json.dumps(default=): pass a callable that handles types no built-in handler matched, or None for strict mode that raises on unknown types.

# strict mode — raise on anything we can't serialise
@cached(cache, default=None)
def f(x: dict) -> str:
    ...

# custom fallback — e.g. include a deterministic hash for opaque objects
def my_default(obj):
    return obj.cache_key() if hasattr(obj, "cache_key") else hashlib.md5(repr(obj).encode()).hexdigest()

@cached(cache, default=my_default)
def g(complicated_input) -> dict:
    ...

The package default is default=str, which preserves the loose 0.1 behaviour of falling back to str(obj). Use strict mode when your inputs include objects without __dict__ whose str(obj) includes a memory address — those addresses change every process invocation and would silently bust the cache key.

Cache identity & migration

Every @cached function has a stable identity — "name:body_hash" — which combines with the per-call argument hash to form the cache key. cache_id() returns it, and func.__emboss__ carries the full metadata:

from emboss import cache_id

@cached(cache)
def fetch_user(uid: int) -> dict:
    ...

cache_id(fetch_user)   # "fetch_user:3f2a9c..." (32-hex hash of the AST-canonical, docstring-stripped source)

The emboss id CLI prints the same token without writing a script — handy for capturing an identity before (or after) an edit:

emboss id mypkg.users:fetch_user            # dotted module path
emboss id src/mypkg/users.py:fetch_user     # file path
emboss id --rev HEAD~1 src/mypkg/users.py:fetch_user   # identity as of a git revision

--rev reads the file out of git (git show), so you can recover the pre-edit identity even if you forgot to capture it first. It imports the module to do this, so it's best-effort: if the module's imports fail, it reports the error and exits 1.

also_accept — keep warm entries through a rename or refactor

Renaming a function or editing its body changes its identity, so existing entries stop matching. When the behaviour is unchanged, pass the old identity and emboss falls back to the old keys on a miss — copying each hit forward to the new key (write-through), so the fallback can be dropped once the cache has migrated:

old_id = cache_id(fetch_user)   # capture before the rename, e.g. "fetch_user:3f2a9c..."

@cached(cache, also_accept=["fetch_user:3f2a9c..."])
def get_user(uid: int) -> dict:
    ...  # same behaviour, new name — old entries are reused, not recomputed

Different arguments still miss as usual — migration only redirects keys, never serves a value computed for other inputs. Malformed tokens (anything not "name:body_hash") raise ValueError at decoration time.

One migration is built in: entries written by emboss < 0.12 were keyed on docstring-sensitive source, so the decorator always accepts that identity as an implicit fallback (tried after any explicit also_accept tokens) and copies hits forward — upgrading keeps the cache warm with no action needed. The fallback is derived from the source as it exists now, so it only matches entries written under a byte-identical docstring: upgrade first and run once (entries migrate forward), then edit docstrings. Any source change to a docstring-bearing function made in the same step as crossing the < 0.12 boundary — a rename, a body refactor, or the docstring edit itself — misses the fallback, and emboss id --rev <old-rev> can't recover the old token because it computes the current (docstring-stripped) scheme. To migrate those entries, derive the token by hand: "oldname:" + md5 of ast.unparse(ast.parse(textwrap.dedent(inspect.getsource(f)))) of the old source without docstring stripping — getsource of the decorated function, so the @cached(...) lines are part of the hash.

unsafe_manual_key — opt out of source-based invalidation

unsafe_manual_key pins the identity to a fixed string instead of the source hash:

@cached(cache, unsafe_manual_key="v1")
def summarise(text: str) -> str:
    ...  # edit freely — entries keyed on "summarise:v1" keep matching

Warning: this disables emboss's invalidate-on-edit safety net. Editing the body no longer invalidates the cache, so stale results are served until you bump the key ("v1" → "v2"). Use it only when you accept that responsibility — e.g. a hot cache you must not re-bill for cosmetic-but-not-quite-canonical churn. also_accept works alongside it, e.g. to migrate source-keyed entries into a manual-key identity.

cache_key / cache_keys — where would this call land?

cache_key(fn, *args, **kwargs) returns the exact storage key fn(*args, **kwargs) reads and writes — derived by the wrapper's own keying logic, so it can never drift from what @cached actually does. cache_keys additionally returns the also_accept fallback keys:

from emboss import cache_key, cache_keys

key = cache_key(get_user, 42)          # "89ab...": md5(name + body_hash + arg_hash)
key in cache                           # is this specific call cached?

key, accept_keys = cache_keys(get_user, 42)   # + the fallback keys `also_accept` would try

This makes migrations first-class: read an entry under one call's key and write it under another's, without recomputing. Both raise TypeError on a function that isn't @cached-wrapped.

Cache-only mode — prove a run makes zero external calls

When @cached functions wrap paid APIs, "this re-run is fully cached" is a guarantee worth enforcing, not assuming. Cache-only mode makes any genuine miss — the current key and every also_accept fallback absent — raise emboss.CacheMiss instead of executing the function:

import emboss

with emboss.cache_only():
    run_all_monitors()   # every @cached call must resolve from cache;
                         # anything uncached raises CacheMiss instead of paying

or process-wide, without touching code:

EMBOSS_CACHE_ONLY=1 python rerun_monitors.py   # "1"/"true"/"yes" enable; ""/"0"/"false"/"no"/"off" disable;
                                               # anything else raises ValueError rather than silently unsealing

The env var is read live (not snapshotted at import), and either trigger suffices. The block scope is the calling thread's current context (isolated per async task) and restores the prior state on exit, even on exception. Worker threads do not inherit the block: threading.Thread / ThreadPoolExecutor workers start from a fresh context and fall back to the env var — seal a multi-threaded run with EMBOSS_CACHE_ONLY=1 instead (asyncio.to_thread is safe; it copies the caller's context). emboss.cache_only(enabled=False) force-disables the mode inside a block — e.g. to carve one deliberately-recomputed call out of an env-var-sealed run.

What still counts as a hit (returns normally, never raises):

  • Cached values of any shape — including a cached None or a stored negative/known-miss result: they are real entries, distinct from the internal absent-sentinel.
  • also_accept fallback hits — an entry warm under an old identity is served and still migrated forward (write-through), so cache-only re-runs double as migrations.

CacheMiss (a RuntimeError) carries func_name, cache_id, and key as attributes; its message includes only the function name and truncated hashes — never the call's arguments, which may be large or secret:

CacheMiss: cache-only mode: no cached entry for 'grade' (identity grade:3f2a9c1b…, key 89abcdef…); refusing to execute

Async functions raise the same way — awaiting a missed call inside cache-only mode raises CacheMiss. The mode is checked when the coroutine runs, not when it is created: a task started with asyncio.create_task inside the block stays sealed, but a bare coroutine created inside the block and only awaited after it exits executes normally.

Concurrent misses compute once — and the first write wins

A cache that folds records keep-latest is only "never a wrong value" for a deterministic function. Wrap an LLM call with no API seed and every redundant recompute of an already-cached key draws a different sample — and the newer set is what every later reader serves, so the value the first caller returned (and maybe published) silently changes underneath them. In one production corpus 0.29% of 14.9M keys held such conflicting records, 92% of them from one process dispatching the same key twice within a minute.

@cached closes that window in two layers:

  • Per-key in-flight latch (per process). A miss holds its key's latch — threads and asyncio tasks alike — while it computes and stores. A concurrent caller of the same key waits, then reads the stored value instead of computing its own. Distinct keys never serialise; a raising holder releases the latch; a function recursing into its own key gets the RecursionError it always did, not a deadlock.
  • Re-check before store (best-effort across processes and nodes). After computing, the miss re-reads its key. If another writer landed a value meanwhile, that value is served and this call's result is discarded — logged at WARNING when the two differ, INFO when identical. A peer's write inside the backend's index staleness, or one not yet synced in from another node, can still supersede on read; a stored value that no longer rehydrates under the current class is not a competing write, and the fresh result heals it.

Nothing to configure: this is how every @cached miss behaves.

Backends (Cache protocol)

cached accepts any object satisfying the runtime-checkable Cache protocol — .get(key, default=...) and .set(key, value). Structural typing, no inheritance:

@runtime_checkable
class Cache(Protocol):
    def get(self, key: str, default: Any = None) -> Any: ...
    def set(self, key: str, value: Any) -> Any: ...

emboss ships three dependency-free backends; diskcache.Cache still works if you pip install emboss[diskcache].

backend storage bounded? shared across hosts? use when
SqliteCache (default) one SQLite DB ✅ size_limit + eviction + TTL ❌ SQLite locking breaks on NFS a bounded local cache (multi-process-safe)
FanoutCache N sharded SQLite DBs ✅ ❌ a local cache under heavy concurrent writes
FileCache one file per key ✅ optional size_limit + LRU ✅ atomic rename, syncable a cache on a network mount / replicated across machines
LogCache per-writer append logs best-effort ✅ conflict-free, few inodes many small entries on a shared/synced mount
diskcache.Cache SQLite (+ tags, stats) ✅ ❌ you want diskcache's richer feature set

Throughput (local SSD, 512-byte values, order of magnitude — python scripts/bench.py):

backend set get
SqliteCache ~10⁴/s ~10⁵/s
FileCache ~10⁴/s ~10⁴/s
LogCache ~10⁴/s ~4×10⁴/s (warm index + frame re-read)

SqliteCache is multi-process-safe (SQLite busy_timeout + BEGIN IMMEDIATE + a "database is locked" retry), keeps size/count accurate via DB triggers (so size_limit holds across processes and len()/volume() are O(1)), spills values ≥ 32 KB to side files to keep the DB small, and runs auto_vacuum=FULL so the DB shrinks as entries are evicted.

SqliteCache — bounded, dependency-free (the default)

from emboss import SqliteCache, cached

cache = SqliteCache(".data/cache", size_limit=2**30)  # 1 GiB, LRU-evicted

@cached(cache)
def expensive(x: int) -> dict:
    ...

One SQLite DB; size_limit bytes (default 1 GiB) with eviction, plus optional per-entry TTL (cache.set(key, value, expire=3600)) and an expire() sweep. Stdlib only — this is what replaced the diskcache dependency.

Eviction policy (eviction_policy=): least-recently-stored (default) orders victims by store time and needs no write on read; least-recently-used refreshes recency on read but, when reads land > 60 s apart, each read becomes a write transaction — benchmarks at ~8–9× slower reads in that worst case (≈ equal to LRS in steady state, where a 60 s throttle suppresses the rewrites). Default to LRS unless you specifically need recency-aware eviction.

FanoutCache — sharded for write concurrency

from emboss import FanoutCache, cached

cache = FanoutCache(".data/cache", shards=8)  # 8 independent SQLite DBs

@cached(cache)
def expensive(x: int) -> dict:
    ...

A single SQLite DB serialises writers on one lock. FanoutCache spreads entries across shards independent SqliteCache databases (each with its own lock), so writes to different keys mostly proceed in parallel — useful for a process pool or async fleet hammering one cache. Routing uses a stable md5 hash of the key (not Python's salted hash()), so every process/node agrees on the shard. size_limit is split evenly across shards; len()/volume()/clear()/iteration aggregate.

FileCache — NFS-safe / replication-safe

from emboss import FileCache, cached

cache = FileCache(".data/cache")                     # unbounded
cache = FileCache(".data/cache", size_limit=2**30)   # bounded + LRU

@cached(cache)
def expensive(x: int) -> dict:
    ...

A single-file cache can't be shared across hosts: SQLite file-locking breaks over NFS — two nodes on the same VAST mount get sqlite3.OperationalError: locking protocol — and one growing DB can't be replicated by a file syncer (Syncthing, rsync) without torn-read corruption. FileCache writes one file per key via tempfile + os.replace (atomic, NFS-safe), so each entry is independent and syncs cleanly. The cost is one inode per key; for a bounded local cache prefer SqliteCache.

size_limit (bytes) is optional — None (default) is unbounded; when set, least-recently-used entries (by file mtime, bumped on read) are evicted past the limit. Eviction is a full-directory scan (best-effort, amortized), and across a syncing fleet it's per-node with deletions propagating.

LogCache — replication-safe with few inodes

from emboss import LogCache, cached

cache = LogCache(".data/cache")  # writer_id defaults to the hostname

@cached(cache)
def expensive(x: int) -> dict:
    ...

FileCache is sync-safe but writes one file per key — millions of inodes for a big cache, and slow du/rsync/Syncthing scans. The naive fix (bundle keys into one file per prefix) makes sync worse: two nodes rewriting the same bundle means a syncer's last-write-wins drops a whole node's chunk of entries.

LogCache gets few inodes and conflict-free sync by giving every writer its own files. Entries are sharded into 256 prefixes; within each, a node appends to directory/<prefix>/<writer_id>.log. Because no file is ever written by two nodes, a syncer just ships each node's logs around — last-write-wins never fires, so a conflict can't lose data. Reads merge a prefix's logs (frame locations cached in memory — never values, not even keys; rebuilt when a peer's log grows); deletes append tombstones; compaction (auto past max_log_bytes, or compact()) rewrites this node's own log dropping dead records. Same-node processes are serialised by a per-writer lock file.

Consolidation / GC compacts this node's log against every writer's records (a record superseded under any writer is dropped from ours), migrates our own legacy spill layout into the shared pool, and mark-and-sweeps the pool. It never touches a peer's log by default: under a file syncer a peer's log here is a replica whose owner may hold an un-synced tail — locally byte-stable is not complete, and a deletion here propagates back and destroys the owner's original (the 2026-07-10 fleet-cache clobber). Cross-writer GC — folding a retired writer's log into ours and removing it, bounding the file count (≈ #prefixes × #writers) as writers come and go — is explicit-only: consolidate(prune_writer_ids={...}) names the writers the caller asserts will never write again (decommissioned hosts, one-shot bulk-import jobs, retired container ids). Even a pruned log is snapshotted (size, mtime) and re-stated before deletion, so an append landing during the pass is never lost. The one owner-agnostic exception is a syncer-minted conflict copy (<writer>.sync-conflict-<ts>-<device>.log): read by the *.log glob but appended-to by nobody, and — unlike a foreign canonical log — complete, immutable and locally finalized, so consolidation folds it into our log and deletes it on every consolidation pass — an explicit consolidate(), or the size-triggered auto-pass on a lone-canonical-writer prefix (a multi-writer prefix's auto-sweep is compaction, which leaves its conflict copies for the next consolidate()) — otherwise conflict copies accrete forever after a conflict event.

In the benchmark, 20,000 entries used 512 files (vs FileCache's 20,000) — the inode count is capped at #prefixes × #writers, independent of entry count. A read is an in-memory location lookup plus one page-cached frame read (~4×10⁴ get/s — see the benchmark block in emboss/_log_cache.py for the canonical numbers), so reads are slower than SqliteCache's. That is a deliberate trade: the index holds packed frame locations — no values and no keys — so it costs ~30 B per live key regardless of value size, and a process's footprint no longer scales with the corpus it sweeps (before 0.10.0 a warm sweep over a 53 GB cache held ~30 GB of unpickled values per process). index_ttl=1.0s throttles the freshness re-stat for peers' appends, at the cost of up to index_ttl of staleness on cross-process/node writes (own writes are immediate, and 1 s is well under Syncthing's latency).

Large values spill to side files (min_file_size, default 32 KB), keeping the append log small — verified on touchstone's real cache, where a 185 MB value spilled to a side file and the log stayed at 120 bytes. Spill files live in a per-prefix shared, content-addressed pool (<prefix>/spill/<sha256>.val — identical values collapse to one file, and two nodes producing the same name write identical bytes, so sync stays conflict-free) and are collected only by consolidation's mark-and-sweep, never on the write path. A lone-writer prefix sweeps automatically via the max_log_bytes trigger; a multi-writer prefix sweeps only on an explicit consolidate().

Tunables (python scripts/bench.py picked the defaults): index_ttl (1.0 s), prefix_width (2 → 256 shards; use 3 above ~2M entries), max_log_bytes (4 MB), min_file_size (32 KB). writer_id must be unique per node (defaults to the hostname); prefix_width must match across writers of a directory.

Migrating between backends — transfer

from emboss import transfer, SqliteCache, LogCache

transfer(SqliteCache(".old"), LogCache(".new"))   # returns the count copied

transfer(source, destination, *, clear_source=False) copies every entry verbatim (the stored encoding), so a cache written by @cached stays readable by @cached on the destination. Use it to switch backends (diskcache → SqliteCache), re-shard a FanoutCache, or consolidate logs. The source must be iterable — every emboss backend (SqliteCache, FanoutCache, LogCache, FileCache) and diskcache.Cache is. (FileCache stores each entry's key alongside its value so keys are recoverable; entries written by older versions held only the value and are skipped.) Keys that could not be copied — expired mid-transfer, unreadable in this environment (import skew), or refused by the destination — are counted, warned about, and cause clear_source=True to be skipped so the only copy of what wasn't transferred survives.

All backends implement the diskcache.Cache subset @cached uses (get, set, __contains__, __getitem__, __setitem__, __delitem__, delete, clear, close, context-manager) and accept-and-ignore extra diskcache kwargs (timeout, ...) so call sites switch with no code changes.

Async support

@cached(cache)
async def fetch_async(url: str) -> dict:
    async with httpx.AsyncClient() as c:
        return (await c.get(url)).json()

Cache hits return a fresh awaitable wrapping the cached value, so the call site keeps await-ing as normal.

Daily-rolling caches

The cache instance you pass is yours to manage. For per-entry expiry use SqliteCache's TTL (cache.set(key, value, expire=86400)). For a coarser "fresh every day", point the directory at today's date:

from datetime import date
from emboss import SqliteCache
cache = SqliteCache(f"/tmp/my-cache-{date.today()}")

Each new day → new dir → effectively fresh cache. Old dirs land in /tmp and get reaped by the OS.

License

MIT.

Metadata

Release files for emboss 0.12.1

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

Source distribution (sdist)

Source distribution for emboss 0.12.1
File Size Uploaded
emboss-0.12.1.tar.gz 133.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for emboss 0.12.1
File Interpreter ABI Platform
emboss-0.12.1-py3-none-any.whl Python 3 none any Details

Total release size: 270.2 kB

Release files / emboss-0.12.1.tar.gz

Download URL emboss-0.12.1.tar.gz
Size 133.7 kB
Tags Source
SHA-256 checksum
How to use checksums
53abb779566e686c207735582eef7813baf64bfe5ee89e265ba6e33f12a4b1a8
BLAKE2b-256 checksum
How to use checksums
81e336fd591b0f03ceec7edf859439677db6e17604cc84f79645f9d258fddbe1
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 Sep 2, 2026.

Transparency log

Release files / emboss-0.12.1-py3-none-any.whl

Download URL emboss-0.12.1-py3-none-any.whl
Size 136.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0b34d811e3fc2c419cec09c469b94c8ba202ed3d15ff53ff3ee5b5f86fbc10c6
BLAKE2b-256 checksum
How to use checksums
11f505307c681168b5e73798c979292a79a1855e84d372da5bf9db0cbf1933e8
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 Sep 2, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.12.1 This release

2 release files

0.12.0

2 release files

0.11.1

2 release files

0.11.0

2 release files

0.10.0

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.5

2 release files

0.7.4

2 release files

0.7.3

2 release files

0.7.2

2 release files

0.7.1

2 release files

0.7.0

2 release files

0.6.3

2 release files

0.6.2

2 release files

0.6.1

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.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