falaw.canonical#
One canonical byte-form for everything falaw hashes (falaw#17).
falaw derives money-bearing identities from JSON: the per-call cache key
(falaw.cache._key()), the whole-plan idempotency handle
(falaw.plan.plan_hash()), and the dry-run artifact id
(falaw.plan._synthetic_artifact). Before this module each site called
json.dumps(..., sort_keys=True, default=str) — and default=str is a
give-up-quietly branch inside a key-composition function, which fails in
both directions at once:
two structurally different values whose
str()coincide collapse to one key —Decimal("0.5")vs"0.5",Path("/a/b")vs"/a/b"— a silent wrong hit: the caller is handed someone else’s artifact and billed it as a saving;an object without a stable
str()(the default<Ref object at 0x...>repr) mints a fresh key per instance — a permanent miss plus unbounded cache growth.
JSON’s silent key coercion has the same collision property from the other
side: {1: "x"} and {"1": "x"} dump to identical bytes, so two
different argument dicts share a key. (lacing rejects the same case in
its digests, for the same reason.)
The rule this module enforces: **a key-composition function must never have a
“give up quietly” branch.** canonical_blob() serializes with
sort_keys=True, no default, allow_nan=False, and string-only
mapping keys — anything else raises falaw.errors.FalNonCanonicalArgument
naming the offending path and type, so the failure is a diagnosable refusal at
plan time rather than a wrong artifact at collect time.
Two payload projections live here side by side, on purpose:
cache_key_payload()—{app, args}, the per-call content-addressed cache key;plan_identity_payload()—{app, args, tool}, the structural idempotency form shared byplan_hashand the dry-run artifact id.
The field sets differ deliberately (tool is a falaw-side label; the
underlying fal call is the same whatever the tool was called), but a field
that changes what the call produces must go into both — in this module,
in one commit. backend (falaw#15’s CallPlan.backend) is exactly that
field: both projections accept it, and it lands in the hashed payload
whenever it is not DFLT_BACKEND — omitted, not merely equal, when it
is. Two consequences, both load-bearing:
a backend added to
plan_hashbut not the cache key would let one backend return another backend’s artifact as a wrong hit — the reason the two projections take the parameter together rather than one growing it first;omitting the field for the default backend means every call falaw has ever planned or cached — all of them
"fal"today — keeps its exact pre-#15 digest. A future non-default backend hashes under a genuinely different key (correctly — it is a different call), without silently invalidating the deployed fal cache the day this ships.
Byte-compatibility: for JSON-native payloads the output of
canonical_blob() is identical to the old form, so every cache entry
whose arguments were already JSON-native keeps its key. Invalidated —
deliberately, because their keys were unreliable or unportable — are entries
whose arguments needed any of JSON’s laxities: the default=str
stringification, a bare NaN/Infinity (previously serialized natively),
or silently-coerced non-string mapping keys. tests/test_canonical.py pins
the stability half with literal digests.
Module Attributes
The only execution backend falaw ships until a second one lands (falaw#15). |
Functions
|
The one byte-form falaw hashes: sorted keys, no fallback, no NaN. |
|
Raise |
|
|
|
|
- falaw.canonical.DFLT_BACKEND = 'fal'#
The only execution backend falaw ships until a second one lands (falaw#15). The single source for two defaults that must agree:
CallPlan.backend’s field default, and the valuecache_key_payload()/plan_identity_payload()omit from the hashed payload.
- falaw.canonical.cache_key_payload(application, arguments, *, backend='fal', key_extra=None)[source]#
{app, args}— what the per-call content-addressed cache keys on.No
tool: two CallPlans differing only in falaw-side labelling make the same fal call and should share a cache entry.backendjoins the payload only when it is notDFLT_BACKEND— see the module docstring for why that asymmetry, not unconditional inclusion, is the safe choice. A field that changes what the call produces must be added here AND inplan_identity_payload().key_extrajoins under the same omit-if-empty rule: identity a caller declares beyond the wire arguments (nw#27’s Transformimpl_versionis the first customer — “same interface, changed behaviour” must miss the cache without renaming anything). Empty means absent, so every key ever issued without one is unchanged.- Return type:
- falaw.canonical.canonical_blob(payload)[source]#
The one byte-form falaw hashes: sorted keys, no fallback, no NaN.
Raises
FalNonCanonicalArgumentfor anything JSON cannot represent faithfully — there is deliberately nodefault=escape hatch, because a hashing function that guesses is a hashing function that collides.- Return type:
- falaw.canonical.ensure_canonical(payload, *, context='arguments')[source]#
Raise
FalNonCanonicalArgumentunlesspayloadis canonical JSON.Canonical means:
str/bool/int/ finitefloat/Nonescalars,list/tuplesequences, and mappings with string keys, recursively. Call this at a boundary (make_call_plan,cached_call_fal) so the refusal happens while it is still free — before any network call, not on the way to one.- Return type:
- falaw.canonical.plan_identity_payload(application, arguments, *, tool, backend='fal', key_extra=None)[source]#
{app, args, tool}— the structural form behindplan_hashand the dry-run artifact id.Includes
toolso a re-plan of the same request is recognizable as the same plan even though the cache would treat the calls identically.backendandkey_extrajoin the payload under the same omit-if-default rule ascache_key_payload(); keep the two functions adjacent so adding a field to one is an explicit decision about the other.- Return type: