yggdrasil.dataclasses¶
dataclasses ¶
Enhanced dataclass helpers with Arrow awareness.
WaitingConfig
dataclass
¶
WaitingConfig(
timeout: float = DEFAULT_TIMEOUT_TICKS,
interval: float = 0.5,
backoff: float = 1.5,
max_interval: float = 10.0,
retries: int = 4,
max_attempts: Optional[int] = 4,
)
sleep ¶
iteration is 0-based (first wait => iteration=0)
Backoff sleep strategy: - interval == 0 => no sleep - backoff >= 1 => interval * backoff**iteration - max_interval == 0 => no cap, else cap sleep to max_interval - if start is provided and timeout > 0: * raise TimeoutError if already out of time * cap sleep so we don't oversleep past timeout
Awaitable ¶
Bases: ABC
progress ¶
A 0..1 completion fraction for a progress bar, or None if unknown.
A UI hook: a generic awaitable can't know its fraction, so the base
returns None (drive a spinner, not a bar). Subclasses that do know
— a batch's children done, a statement's rows fetched — override this.
Consumed by :func:yggdrasil.cli.style.track.
watch ¶
Drive to completion, calling on_tick(self) each poll.
The hook a UI (spinner / progress bar) connects to without this trait
importing any UI — keeping the layering clean. Starts the awaitable if
it hasn't been, polls until done, then surfaces a failure (unless
raise_error is False). Pairs with :func:yggdrasil.cli.style.track.
AwaitableBatch ¶
Bases: Awaitable
watch ¶
Drive to completion, calling on_tick(self) each poll.
The hook a UI (spinner / progress bar) connects to without this trait
importing any UI — keeping the layering clean. Starts the awaitable if
it hasn't been, polls until done, then surfaces a failure (unless
raise_error is False). Pairs with :func:yggdrasil.cli.style.track.
progress ¶
Fraction of child awaitables finished — drives a real progress bar.
RefreshResult
dataclass
¶
RefreshResult(
value: Optional[T],
created_at_ns: int | None = None,
ttl_ns: int | None = None,
expires_at_ns: int | None = None,
)
Bases: Generic[T]
Output of _refresh().
Provide either:
- ttl_ns (duration) OR
- expires_at_ns (absolute time)
If both are None → non-expiring.
make
classmethod
¶
make(
value: Optional[T] = None,
*,
created_at_ns: Optional[_IntLike] = None,
ttl_ns: Optional[Union[_IntLike, timedelta]] = None,
expires_at_ns: Optional[_IntLike] = None
) -> "RefreshResult[T]"
Convenience constructor with defaults + light casting.
Expiring
dataclass
¶
Expiring(
_value: Optional[T] = None,
_created_at_ns: int = now_utc_ns(),
_expires_at_ns: Optional[int] = None,
_ttl_ns: Optional[int] = None,
)
Bases: Generic[T], ABC
Abstract thread-safe expiring cache holder.
Subclasses implement _refresh() which is called when .value is
accessed and the cache is expired.
ExpiringDict ¶
ExpiringDict(
default_ttl: Optional[Union[float, int, timedelta]] = 300.0,
*,
max_size: int | None = None,
refresher: Optional[Callable[[K], RefreshResult[V]]] = None,
on_evict: Optional[Callable[[K, V], None]] = None
)
Bases: Generic[K, V]
Thread-safe dictionary where every key carries an individual TTL.
Built on the same nanosecond time-utils as Expiring but
fully lockless — every mutation rides the CPython GIL atomicity
of dict.__setitem__ / dict.pop / list(dict.items())
with permissive race semantics, so the cache can never deadlock.
No subclassing required.
Parameters¶
default_ttl :
Default TTL as seconds (float), nanoseconds (int),
timedelta, or None (keys never expire unless given a per-key
TTL).
max_size :
Evict the soonest-to-expire key when capacity is reached.
refresher :
Optional Callable[[key], RefreshResult[V]]. When supplied,
an expired get will call refresher(key) and atomically replace the
entry rather than returning the default/raising.
on_evict :
Optional Callable[[key, value], None] invoked after every
removal — TTL-driven sweeps, capacity evictions, explicit deletes,
pop, clear, refresher replacements, __setitem__
overwrites of an existing key. The callback runs outside the
cache's lock so it can do real work (close a file handle, unlink
a temp file, decrement a refcount) without serializing other
cache ops. Exceptions raised by the callback are swallowed so a
bad callback can't poison the cache; if the callback's failure
matters to the caller, they should log it themselves.
Serialization¶
__getstate__ / __setstate__ are implemented: only live
(non-expired) entries are persisted. The on_evict and
refresher callbacks are NOT persisted — they're typically
closures over runtime state. Compatible with pickle,
copy.deepcopy, and joblib.
set ¶
Insert or overwrite key.
ttl accepts seconds (float), nanoseconds (int),
timedelta, or None (no expiry). Omitting ttl uses the
instance default. Schedules a background purge every 15 minutes.
Fires on_evict for any entry that was capacity-evicted to
make room or whose key got overwritten.
Lock-free. dict.__setitem__ / dict.pop /
dict.get are GIL-atomic; the capacity check and
overwrite-notify bookkeeping run under a permissive race
regime (under heavy contention the cache may briefly hold
max_size + 1 entries or notify a value that another
thread already replaced — both harmless).
get ¶
Return value for key, or default if missing / expired.
set_many ¶
Insert multiple key-value pairs sharing a TTL.
Lock-free; each insert lands via GIL-atomic
dict.__setitem__. The batch is no longer atomic
end-to-end — a concurrent reader may observe a partially
applied batch — but each individual entry's visibility is
atomic, which is all a cache needs.
Fires on_evict (after the writes) for any entries that
got capacity-evicted or overwritten.
update ¶
update(
other: Union[Dict[K, V], "ExpiringDict[K, V]", None] = None,
ttl: Any = ...,
**kwargs: V
) -> None
Update the dict from a mapping and/or keyword arguments, mirroring
the stdlib dict.update signature.
Parameters¶
other :
A plain dict, another ExpiringDict, or any object with an
.items() method. When the source is an ExpiringDict and no
explicit ttl is given, each key's remaining TTL is preserved
so expiry semantics survive a copy. Already-expired source keys
are silently skipped.
ttl :
Override TTL for every key written. Accepts seconds (float),
nanoseconds (int), timedelta, or None (no expiry).
When omitted, the instance default is used for plain-dict sources,
or the source key's remaining TTL is used for ExpiringDict
sources.
kwargs :
Additional key-value pairs merged after *other, using *ttl /
the instance default.
get_many ¶
Return {key: value} for all live keys in keys.
Pure read — no eviction side-effect. Lock-free; relies on
the GIL atomicity of dict.get. Expired entries are skipped
but left in place for the background purge / next get to
evict (the previous pop-on-expire path raced with concurrent
refreshers).
delete_many ¶
Delete keys; returns count of keys actually removed.
ttl ¶
Remaining TTL in seconds for key, or None if gone/expired.
Pure inspection — no eviction side-effect. (The previous
evict-on-expired variant raced with concurrent refresh and
could drop a freshly-set value; lazy eviction happens in
get and the background sweep.)
snapshot ¶
Shallow copy of live entries as {key: (value, expires_at_ns)}.
Useful for debugging or external persistence.
clear ¶
Drop every entry. Fires on_evict for each removed entry.
Lock-free. When on_evict is wired up we snapshot first,
then call dict.clear() — entries inserted between the
snapshot and the clear get cleared too but won't trigger the
callback (permissive). When no callback is set, the fast
path is a single GIL-atomic dict.clear() call.
purge_expired ¶
Explicitly evict all expired keys; returns count removed.
Fires on_evict for every entry that expired.
refresh_key ¶
Reset the TTL of an existing, live key.
Returns True if key existed and was refreshed, False otherwise.
Fires on_evict ONLY when the call discovers a silently
expired entry and drops it; never when the key is genuinely
refreshed.
Lock-free pop+reinsert. Permissive race: a concurrent writer that lands between our pop and our reinsert will be overwritten by ours (we restore the previous value with a new TTL). Acceptable for a TTL-bump operation.
get_or_set ¶
Return the live value for key; if missing/expired, store default (or the result of calling it) and return that.
Fires on_evict if a previously-stored expired entry got
replaced — the old value is leaving the cache.
Note: the default callable is invoked OUTSIDE the internal
lock. This rules out a class of deadlocks where default()
takes another lock (or re-enters this cache from a different
thread). The tradeoff is that under contention two threads
may both invoke default() — the first writer wins, the
loser silently discards its own computation.
apply_refresh_result ¶
Store the outcome of an external RefreshResult for key.
Mirrors the contract from Expiring._apply_refresh_result.
Singleton ¶
Base class that caches one instance per hashable constructor key.
The cache is shared across every subclass (the default
_singleton_key includes cls so different subclasses can
coexist in one dict). A subclass that wants a private cache
re-declares its own _INSTANCES ClassVar.
No mutex anywhere — :meth:__new__, :meth:to_singleton, and
:meth:invalidate_singleton all ride :class:ExpiringDict's
lockless GIL-atomic primitives (get_or_set for atomic
check-and-insert, pop for atomic identity-guarded remove).
Cannot deadlock by construction.
to_singleton ¶
Promote this instance into the per-class _INSTANCES cache.
Hot listing paths (iterdir / _ls / glob) build
children with singleton_ttl=False so the bounded cache
doesn't fill up with thousands of short-lived entries. When a
caller decides one of those children is worth keeping around
(handing it to a long-running worker, returning it from an
API), :meth:to_singleton registers self into the cache
so the next constructor call with the same key collapses to
the same instance.
ttl defaults to the subclass's _SINGLETON_TTL
(... = no caching, None = process lifetime, or a
seconds count). When a different instance is already cached
under this key, that pre-existing one wins and is returned
unchanged — the cache is the source of truth.
invalidate_singleton ¶
Pop self from the per-class _INSTANCES cache.
Mutating ops on a Singleton-cached object (writes, deletes,
schema invalidations on a Databricks table, put_object on
an :class:S3Path) want to make sure the next caller asking
for the same key gets a fresh build rather than collapsing
onto this stale handle — that's what remove_global=True
(the default) does. The pop is :meth:identity-guarded:
only an entry that still points at self is removed, so
a concurrent re-construction that already raced past this
thread is left alone.
remove_global=False is a no-op. The keyword exists so
subclass invalidators (invalidate_singleton,
_invalidate_entity_tag_cache, …) can offer the same
switch without branching at the call site.
get_from_dict ¶
Best-effort field lookup with optional prefix support.
Lookup order for each key
1) obj[key] 2) obj[prefix + key]
Returns:
| Type | Description |
|---|---|
Any
|
|
Any
|
|
default_value ¶
Return the effective default value for a dataclass field.
Returns:
| Type | Description |
|---|---|
Any
|
|
Any
|
|
Any
|
|
serialize_dataclass_state ¶
Serialize constructor state for a dataclass instance.
Rules
- only init=True fields are considered
- private fields (name starts with "_") are skipped
- None values are skipped
- values equal to their effective default are skipped
- output is a raw payload dict with no version envelope
restore_dataclass_state ¶
Restore dataclass state from a raw payload dict.
Rules
- None is treated as {}
- unknown keys are ignored
- missing init=True fields are filled from effective defaults
- missing required init=True fields raise TypeError
- non-init fields are reset to their effective defaults when available
Raises:
| Type | Description |
|---|---|
TypeError
|
If state is not a dict or a required field is missing. |
datetime_to_epoch_ns ¶
datetime → epoch ns (microsecond precision ⇒ trailing *000).
timedelta_to_ns ¶
timedelta → ns (microsecond precision ⇒ trailing *000).
describe_signature ¶
Capture func's signature as a JSON-serializable dict.
Returns {"qualname", "module", "parameters": [...], "return"}
where each parameter entry carries name, kind (the
:class:inspect.Parameter.kind name), annotation (dotted path
when the annotation is a class), and default (repr of the
default) where present.
format_signature ¶
Render :func:describe_signature output as qualname(x: int = 5) -> str.
check_function_args ¶
check_function_args(
func: Callable[..., Any],
args: tuple = (),
kwargs: Optional[Mapping[str, Any]] = None,
) -> tuple[tuple, dict[str, Any]]
Coerce args / kwargs to match func's annotated signature.
Walks :func:inspect.signature and routes each value whose
parameter has a non-empty annotation through
:func:yggdrasil.data.cast.convert. Returns coerced
(args, kwargs) ready to splat back into the call — positional
inputs stay positional, keyword inputs stay keyword (no
pos-to-kw rewrite).
*args (VAR_POSITIONAL) and **kwargs (VAR_KEYWORD)
annotations apply per element, so def f(*xs: int) coerces
every element of xs to int. Excess positional or
unknown keyword inputs (no matching parameter, no **kwargs
catch-all) pass through untouched so the natural TypeError
fires on call rather than being silently swallowed here.
Empty input short-circuits — no yggdrasil import fires.
Hot-path callers that invoke the same function repeatedly
should reach for :func:build_row_invoker instead, which
caches the :class:_SignaturePlan across calls.
build_row_invoker ¶
Return a per-row dispatcher that adapts row shape to func's signature.
Pre-computes the signature once and returns a callable
invoker(row) -> result that:
- Passes
rowdirectly when func has exactly one positional parameter and no**kwargscatch-all (the commondef f(row): ...shape). - Spreads
rowas**kwargswhen func has multiple named parameters or accepts**kwargsandrowis a :class:Mapping. Keys missing from the declared parameter list are dropped unless a**kwargscatch-all is present; keys missing from the row stay unset (so defaults apply). - Spreads
rowas*argswhen func has only a*argscatch-all (no other positional param) androwis a :class:tupleor :class:list. - Falls back to
func(row)for anything else.
Coerces annotated arguments through the pre-built
:class:_SignaturePlan coercer so a function annotated
def f(id: int, name: str) called against a dict with
string keys still gets its id coerced to int.
When the dict-spread call raises TypeError (e.g. the function
rejected the spread shape), the invoker retries with
func(row) once so a row that happens to be a dict but
means "an opaque mapping value" still reaches the function.
Other exceptions propagate.
Picklable: the returned closure references func and the plan
by reference; both pickle through the standard cloudpickle path
used elsewhere in :mod:yggdrasil.pickle.
build_batch_invoker ¶
Return a per-batch dispatcher invoker(batch) -> list[Any].
Three dispatch shapes, picked from func's signature once:
- Whole-batch tabular — when func has a single positional
annotated parameter whose type is a recognised tabular shape
(
pa.RecordBatch/pa.Table/pl.DataFrame/pl.LazyFrame/pd.DataFrame), the entire incoming :class:pyarrow.RecordBatchis converted to that type via the :func:yggdrasil.data.cast.convertregistry and handed to func in one call. The result is returned as a one-element list so the downstream chunker (e.g._typed_cast) can fold it back into Arrow batches alongside results from other partitions. - Column-by-name + primitive target — when func has a single
positional annotated parameter whose name matches a column in
the incoming batch and whose annotation maps to a primitive
Arrow type, the column is cast via :func:
pyarrow.compute.cast(vectorised, one C++ kernel call) and func runs per cell — skipping the per-row dict reconstructionbatch.to_pylist()would otherwise do. - Per-row fallback — for every other shape (multi-arg,
**kwargs, no column name match, annotation that doesn't map to either category) the batch is materialised throughbatch.to_pylist()and dispatched via :func:build_row_invokerper row.
Per-row :func:yggdrasil.data.cast.convert calls collapse into one
pa.compute.cast (column path) or one convert(batch, target)
(whole-batch path) when either fast path applies.
checkargs ¶
Wrap func so every call has its args coerced to the annotated types.
Built on :func:check_function_args — every invocation routes
incoming args / kwargs through the coercion pass before
the real call. Annotated parameters receive values converted via
:func:yggdrasil.data.cast.convert; unannotated parameters pass
through. :func:functools.wraps preserves __name__,
__qualname__, __doc__, __annotations__, and the
underlying __wrapped__ so :func:inspect.signature still
reports the original signature.
Coroutine functions (async def) get an async wrapper that
awaits the underlying call; sync functions get a plain wrapper.
Re-wrapping is idempotent — applying @checkargs twice unwraps
the inner __wrapped__ so the second decoration doesn't add a
second coercion pass.