wallet_helper package
Submodules
- wallet_helper.api module
- wallet_helper.cli_argparse module
- wallet_helper.cli_click module
- wallet_helper.guard module
- wallet_helper.ledger module
- wallet_helper.remote module
- wallet_helper.sqlite_ledger module
- Author
SqliteLedgerSqliteLedger.claim()SqliteLedger.clear()SqliteLedger.evict()SqliteLedger.extend()SqliteLedger.get()SqliteLedger.get_record()SqliteLedger.has()SqliteLedger.heartbeat()SqliteLedger.locationSqliteLedger.put()SqliteLedger.register_hit()SqliteLedger.release()SqliteLedger.stats()SqliteLedger.submit()
Module contents
wallet-helper: never run the same heavy call twice.
Persistent, content-addressed memoization for expensive calls (a paid API request, a slow model, any heavy function). An identical call is served from a local ledger instead of running again, across process restarts, and two identical calls made at the same time collapse into one (single-flight), so the second waits for the first and reuses its result.
The front door is Wallet and the
memoize() decorator. Storage is a
Ledger (a folder of JSON files) or a
SqliteLedger (one shared, concurrency-safe
file, with a cross-process lease).
- class wallet_helper.Ledger(cache_dir=None, max_entries=None)[source]
Bases:
objectA directory-backed store of results, one JSON file per entry.
- Parameters:
cache_dir (str or pathlib.Path, optional) – Where entries live. Defaults to
$WALLET_HELPER_DIRthen~/.cache/wallet-helper.max_entries (int, optional) – A size cap. When set, each
put()also evicts down to the newestmax_entriesentries, so the store cannot grow without bound.None(default) means no automatic bound; callevict()yourself.
Examples
>>> import os_helper as osh >>> with osh.temporary_folder() as tmp: ... Ledger(tmp).has("demo_x") False
- clear(namespace=None)[source]
Delete entries, all or just one
namespace(irreversible).With a namespace, only its entries are removed (this is what a memoized function’s
cache_clear()calls). Without one, the whole directory is removed and recreated lazily on the nextput().- Parameters:
namespace (str | None)
- Return type:
None
- evict(*, max_entries=None, older_than=None)[source]
Prune entries and return how many were removed.
Expired entries (past their
ttl) are always removed. In addition:
- put(key, result, *, ttl=None)[source]
Store
resultforkey(overwrites any previous entry).- Parameters:
key (str) – The ledger key (see
make_key()).result (Any) – The JSON-serialisable result to store.
ttl (float, optional) – Seconds until the entry is considered stale.
None(default) means it never expires. A stale entry is treated as a miss on the next call and is removed byevict().
- Return type:
None
- register_hit(key)[source]
Count one reuse of the cached result for
key(no-op if absent).The read-modify-write runs under a lock so concurrent hits do not lose a count: threads in this process take turns, and separate processes take turns too where advisory file locks are available (POSIX). On Windows the cross-process count is best-effort; use
SqliteLedgerfor an exact count under heavy multi-process concurrency.- Parameters:
key (str)
- Return type:
None
- class wallet_helper.LedgerLike(*args, **kwargs)[source]
Bases:
ProtocolThe storage contract every ledger backend fulfils.
The default
Ledgerkeeps one JSON file per entry, which is ideal for a single process.wallet_helper.sqlite_ledger.SqliteLedgerkeeps everything in one SQLite file for a shared, concurrency-safe store. Awallet_helper.guard.Walletand the command-line tools accept either.- clear(namespace=None)[source]
Remove entries, all of them or just one namespace (irreversible).
- Parameters:
namespace (str | None)
- Return type:
None
- evict(*, max_entries=None, older_than=None)[source]
Prune entries and return how many were removed (see
Ledger.evict()).
- put(key, result, *, ttl=None)[source]
Store
resultforkey(overwrites), expiring afterttlseconds.
- class wallet_helper.RemoteLedger(base_url, *, timeout=30.0, request=None)[source]
Bases:
objectA ledger backed by a wallet-helper HTTP server.
- Parameters:
base_url (str) – Root URL of the server, for example
"http://127.0.0.1:8000". A trailing slash is fine; it is trimmed.timeout (float, optional) – Per-request timeout in seconds. Defaults to 30.
request (callable, optional) – A custom transport
request(method, path, body) -> dict | Noneused instead of the built-inurllibone. Mainly for tests, where it can route to a FastAPITestClient.
- claim(key, lease_seconds=300.0)[source]
Get the cached result, or lease the right to compute it (see the server).
- clear(namespace=None)[source]
Delete results on the server, all of them or just one namespace.
- Parameters:
namespace (str | None)
- Return type:
None
- evict(*, max_entries=None, older_than=None)[source]
Prune results on the server and return how many were removed.
- register_hit(key)[source]
No-op: the server counts reuses itself, on claim hits and result reads.
- Parameters:
key (str)
- Return type:
None
- release(key, token=None)[source]
Drop a lease on the server so a waiter can take over (your own, if fenced).
- class wallet_helper.SqliteLedger(db_path=None, max_entries=None)[source]
Bases:
objectA single-file SQLite store of results, with an in-flight lease table.
- Parameters:
db_path (str or pathlib.Path, optional) – The database file. Defaults to
<default ledger dir>/ledger.db(the same base asLedger, honouring$WALLET_HELPER_DIR). Parent directories are created if missing.max_entries (int, optional) – A size cap. When set, each
put()also evicts down to the newestmax_entriesentries, so the store cannot grow without bound.
Examples
>>> import os_helper as osh >>> with osh.temporary_folder() as tmp: ... lg = SqliteLedger(tmp + "/ledger.db") ... lg.put("demo_x", {"ok": True}) ... lg.get("demo_x") {'ok': True}
- claim(key, lease_seconds=300.0)[source]
Get the cached result, or lease the right to compute it.
- Parameters:
key (str) – The ledger key (see
wallet_helper.ledger.make_key()).lease_seconds (float, optional) – How long a lease is honoured before it counts as abandoned, so a crashed leader cannot block waiters forever. Defaults to 300 s.
- Returns:
{"status": "hit", "result": ...}if it is already computed,{"status": "leased", "token": ...}if you are the leader (compute, thensubmit()with the token), or{"status": "pending"}if another caller is computing it (wait and claim again).- Return type:
- clear(namespace=None)[source]
Delete entries, all or just one
namespace(irreversible).The database file itself remains; only rows are removed.
- Parameters:
namespace (str | None)
- Return type:
None
- evict(*, max_entries=None, older_than=None)[source]
Prune entries and return how many were removed.
Expired entries are always removed. With
older_than, entries created more than that many seconds ago go too. Withmax_entries, only the newestmax_entriesby creation time are kept.
- extend(key, token=None)[source]
Renew a lease so a long job is not treated as abandoned.
A leader running longer than
lease_secondscalls this (directly or throughheartbeat()) to reset the lease clock. Withtokenthe renewal only applies to a lease you still own, so a revived stale leader cannot extend the lease a new leader now holds.
- heartbeat(key, token=None, *, interval=60.0)[source]
Renew
key’s lease everyintervalseconds for the duration of a block.Pass the
tokenfromclaim()so the renewal is fenced to the lease you hold. Wrap a long computation in this so its lease never lapses:>>> import os_helper as osh >>> with osh.temporary_folder() as tmp: ... lg = SqliteLedger(tmp + "/ledger.db") ... lease = lg.claim("job_1") ... with lg.heartbeat("job_1", lease["token"], interval=0.05): ... result = 6 * 7 # a long job, kept alive meanwhile ... _ = lg.submit("job_1", result, token=lease["token"]) ... lg.get("job_1") 42
- put(key, result, *, ttl=None)[source]
Store
resultforkey(overwrites, resetting the hit counter).With
ttlset, the entry expires that many seconds from now and is then treated as a miss on the nextclaim()and removed byevict().
- register_hit(key)[source]
Atomically count one reuse of
key(no-op if absent).The
hits = hits + 1runs as a single statement, so concurrent reuses never lose a count. This is the reason to prefer this backend over the read-modify-write of the JSON one under real concurrency.- Parameters:
key (str)
- Return type:
None
- release(key, token=None)[source]
Drop a lease so a waiter can take over (only your own, if
tokenis given).
- stats(namespace=None)[source]
Count stored entries and their reuses, for the store or one namespace.
- submit(key, result, *, token=None, ttl=None)[source]
Store a leader’s result and release its own lease; return the record.
The result is stored unconditionally (it is deterministic, so a late submit is harmless), but only a lease held by
tokenis released, so a revived stale leader cannot drop a new leader’s lease. Passingtokenfences the release: duplicates coalesce as long as a leader finishes within its lease or keeps a heartbeat (a leader that silently overruns its lease can still be run twice, as with any time-based lease).
- class wallet_helper.Wallet(ledger=None, poll_interval=0.05)[source]
Bases:
objectA ledger plus single-flight around heavy calls.
- Parameters:
ledger (wallet_helper.ledger.LedgerLike, optional) – The result store, any backend satisfying
LedgerLike. With the default JSONLedger, single-flight is in-process. With a claim-capable backend (SqliteLedgerorRemoteLedger) it is cross-process. A defaultLedgeris created when omitted.poll_interval (float, optional) – How often a waiter re-checks a claim-based backend while another caller computes a key. Defaults to 0.05 s.
- async acall(namespace, key_payload, fn, *, ttl=None, stale_while_revalidate=False)[source]
Async twin of
call(): awaitfnonce and cache its result.This is what memoizes an
async defcorrectly: it awaits the coroutine and stores the result, never the coroutine object. Concurrent identical awaits coalesce (async single-flight), and a claim-capable backend still gives cross-process dedup (its blocking calls run in a worker thread).Parameters mirror
call(), exceptfnis a zero-argument callable returning an awaitable.
- call(namespace, key_payload, fn, *, ttl=None, stale_while_revalidate=False)[source]
Return a cached result, or run
fnonce and store it.- Parameters:
namespace (str) – Scope for the call, for example the provider or endpoint name.
key_payload (Any) – What determines the result: arguments, a file path, or bytes. Hashed to the ledger key (see
wallet_helper.ledger.make_key()).fn (callable) – Zero-argument callable doing the heavy work, run at most once per key even under concurrency.
ttl (float, optional) – Seconds the stored result stays fresh. After it expires the next call recomputes.
None(default) means it never expires.stale_while_revalidate (bool, optional) – Only for the in-process
Ledger. When an entry is expired, return the stale result at once and refresh it in a background thread, so callers never wait on the recompute.
- Returns:
The result and
from_cache:Truewhen served from the ledger,Falsewhen this call did the real work. A raisingfnstores nothing, so a failed call is never cached.- Return type:
- paid(namespace, *, key=None, ignore=(), ttl=None, stale_while_revalidate=False)[source]
Decorator memoizing a function through this wallet.
- Parameters:
namespace (str) – Scope for the call.
key (callable, optional) –
key(*args, **kwargs)returning the payload that identifies the result. Overrides the default (all args and kwargs).ignore (tuple of str, optional) – Parameter names to exclude from the cache key, the tidy alternative to a
key=lambda when you just need to drop a volatile handle (for exampleignore=("client",)).ttl (float, optional) – Seconds the stored result stays fresh (see
call()).stale_while_revalidate (bool, optional) – Serve a stale result and refresh in the background (in-process Ledger only; see
call()).
- Returns:
The wrapped function (repeat identical calls are free). It carries
.cache_info()(this namespace’s{entries, hits}) and.cache_clear()(drop this namespace’s entries), likefunctools.lru_cache.- Return type:
callable
Examples
>>> import os_helper as osh >>> from wallet_helper.ledger import Ledger >>> with osh.temporary_folder() as tmp: ... w = Wallet(Ledger(tmp)) ... @w.paid("square") ... def square(n): ... return n * n ... square(9), square(9) # second is free ... square.cache_info()["entries"] # one entry stored (81, 81) 1
- wallet_helper.default_wallet()[source]
Return the process-wide default
Wallet, created on first use.It uses the default
Ledgerlocation ($WALLET_HELPER_DIRthen~/.cache/wallet-helper). Assignwallet_helper.guard._default_walletyourself to point it elsewhere.- Return type:
- wallet_helper.make_key(namespace, payload)[source]
Build a ledger key from a
namespaceand a contentpayload.- Parameters:
namespace (str) – A scope for the call, for example
"transcribe"or"openai.chat", so unrelated calls do not collide even if their payloads hash alike.payload (Any) – See
_digest().
- Returns:
"<namespace>_<hash>", safe to use as a filename.- Return type:
Notes
A
stroros.PathLikeargument that names an existing file is keyed by the file’s content, by design, so the same file reached by a different path still hits (this applies to a path used as an argument or as a value; a plainstrused as a dict key keeps its text). Two consequences worth knowing:A string you meant as plain data (a title, an id) that happens to match an existing file in the working directory is content-addressed too. If that is not what you want, wrap the value so it is not a bare path, or exclude the argument with
ignore=/ akey=builder.If a file is deleted in the brief moment between detection and hashing, the key falls back to the path text for that call. The wrapped function would fail to read the missing file anyway, so no stale result is served.
Examples
>>> make_key("demo", {"b": 2, "a": 1}) == make_key("demo", {"a": 1, "b": 2}) True
- wallet_helper.memoize(fn=None, *, namespace=None, key=None, ignore=(), ttl=None, stale_while_revalidate=False, wallet=None)[source]
Persistent memoization: a cache that survives restarts, plus single-flight.
Drop it on any function and its results are content-addressed to disk, reused across process restarts, and shared between concurrent callers so the same heavy call never runs twice. Works bare (
@memoize) or configured (@memoize(ttl=3600, ignore=("client",))).- Parameters:
fn (callable, optional) – The function, when used bare as
@memoize(filled in by Python).namespace (str, optional) – Cache scope; defaults to the function’s
module.qualnameso distinct functions never collide.key (callable, optional) – Custom key builder
key(*args, **kwargs).ignore (tuple of str, optional) – Parameter names to exclude from the key.
ttl (float, optional) – Seconds the stored result stays fresh.
stale_while_revalidate (bool, optional) – Serve a stale result and refresh in the background (in-process store only).
wallet (Wallet, optional) – The wallet to use; defaults to the shared
default_wallet().
- Returns:
The memoized function, carrying
.cache_info()and.cache_clear().- Return type:
callable
Examples
>>> import os_helper as osh >>> from wallet_helper.ledger import Ledger >>> with osh.temporary_folder() as tmp: ... w = Wallet(Ledger(tmp)) ... @memoize(wallet=w) ... def double(n): ... return n * 2 ... double(21), double(21) (42, 42)