diff --git a/.fern/metadata.json b/.fern/metadata.json index 54f2c10..1dacf97 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -1,7 +1,7 @@ { "cliVersion": "5.50.0", "generatorName": "fernapi/fern-python-sdk", - "generatorVersion": "5.30.0", + "generatorVersion": "5.30.2", "generatorConfig": { "inline_request_params": true, "client": { @@ -23,10 +23,10 @@ } ] }, - "originGitCommit": "96570a6bf7ba01ea0851f22f559b8c62c1c9b5d4", + "originGitCommit": "097aa15ba0f43a259cf91d408a481997d078fb57", "originGitCommitIsDirty": false, "invokedBy": "ci", - "requestedVersion": "1.4.0", + "requestedVersion": "1.4.1", "ciProvider": "github", - "sdkVersion": "1.4.0" + "sdkVersion": "1.4.1" } \ No newline at end of file diff --git a/.fern/replay.lock b/.fern/replay.lock index 1f4d4f3..3aad15d 100644 --- a/.fern/replay.lock +++ b/.fern/replay.lock @@ -66,5 +66,19772 @@ generations: cli_version: unknown generator_versions: fernapi/fern-python-sdk: 5.30.0 -current_generation: 3aabb2b36d7bfed3b7bb2ff95584bd9523b305aa -patches: [] + - commit_sha: d78b634ee1a8e6b3055c0efda25c4a2d25677c0e + tree_hash: 9a75a100fefdf952435829e60e14f669cb830691 + timestamp: 2026-09-15T23:09:10.638Z + cli_version: unknown + generator_versions: + fernapi/fern-python-sdk: 5.30.2 +current_generation: d78b634ee1a8e6b3055c0efda25c4a2d25677c0e +patches: + - id: patch-f12c60b5 + content_hash: sha256:a2167780410c1603bfbf17c8295caff7b906cee1689c070a6b45d7cb144814e9 + original_commit: f12c60b57b45548d24a4921cf78f1005a2c9796f + original_message: add credit leases, reservations, and preflight options (#116) + original_author: Benjamin Papillon + base_generation: 64182c9bfb5fa5c3c083cc528ed823f5532f5bbf + files: + - conformance/README.md + - conformance/SPEC.md + - conformance/vectors/check-flow.json + - conformance/vectors/crash-windows.json + - conformance/vectors/expiry.json + - conformance/vectors/lease-lifecycle.json + - conformance/vectors/lease-manager.json + - conformance/vectors/reservation-lifecycle.json + - conformance/vectors/track-settle.json + - poetry.lock + - src/schematic/leases/__init__.py + - src/schematic/leases/check.py + - src/schematic/leases/lease_manager.py + - src/schematic/leases/lease_store.py + - src/schematic/leases/redis_lease_store.py + - src/schematic/leases/redis_reservation_store.py + - src/schematic/leases/reservation_store.py + - src/schematic/leases/track.py + - src/schematic/leases/types.py + - tests/conformance/test_vectors.py + - tests/lease_support.py + - tests/leases/__init__.py + - tests/leases/conftest.py + - tests/leases/test_check_and_track.py + - tests/leases/test_crash_windows.py + - tests/leases/test_lease_manager.py + - tests/leases/test_lease_store.py + - tests/leases/test_redis_lease_store.py + - tests/leases/test_redis_reservation_store.py + - tests/leases/test_reservation_store.py + - tests/leases/test_wasm_credit_gate.py + - tests/leases/test_wire_client.py + patch_content: | + diff --git a/conformance/README.md b/conformance/README.md + new file mode 100644 + index 0000000..0b0f406 + --- /dev/null + +++ b/conformance/README.md + @@ -0,0 +1,10 @@ + +# Credit lease conformance suite + + + +`SPEC.md` and `vectors/*.json` are copied verbatim from schematic-node + +(`conformance/` on `main`), the reference implementation for client-mode credit + +leases. Do not edit them here: fix or extend them in schematic-node and copy the + +result back, or the SDKs stop pinning the same behavior. + + + +`tests/conformance/test_vectors.py` is this repo's runner. The runner is the + +only language-specific piece; every SDK reimplements it and must pass the same + +vectors. + diff --git a/conformance/SPEC.md b/conformance/SPEC.md + new file mode 100644 + index 0000000..6191f10 + --- /dev/null + +++ b/conformance/SPEC.md + @@ -0,0 +1,446 @@ + +# Credit lease & reservation semantics — conformance spec + + + +This document specifies the client-side credit lease/reservation semantics implemented by the + +Schematic Node SDK (the reference implementation), in enough detail to reimplement them in another + +language without reading the Node source. The machine-readable test vectors in + +`conformance/vectors/*.json` pin the observable behavior; this spec explains the model, the + +configuration knobs, and the invariants that cannot be expressed as deterministic vectors. + + + +Where this document and the vectors disagree, the vectors win — they are generated from the + +reference implementation's behavior. + + + +- [Vector format](#vector-format) + +- [Model overview](#model-overview) + +- [State](#state) + +- [Store operations](#store-operations) + +- [Lease manager](#lease-manager) + +- [Check flow](#check-flow) + +- [Track / settle flow](#track--settle-flow) + +- [Configuration knobs](#configuration-knobs) + +- [Bounded-leak contract](#bounded-leak-contract) + +- [Invariants not expressible as vectors](#invariants-not-expressible-as-vectors) + + + +## Vector format + + + +Each file in `conformance/vectors/` is a JSON document: + + + +```json + +{ + + "category": "reservation_lifecycle", + + "vectors": [ + + { + + "name": "unique_snake_case_name", + + "description": "What this vector pins and why.", + + "backends": ["in_memory", "redis"], + + "given": { + + "config": { "lease_duration_ms": 300000, "reservation_ttl_ms": 60000, "lease_size": 1000, "low_water_mark": 0.25 }, + + "leases": [ { "lease_id": "lse_1", "company_id": "co_1", "credit_type_id": "ct_1", "granted_amount": 1000, "expires_at_ms": 60000 } ] + + }, + + "operations": [ + + { "op": "try_reserve", "company_id": "co_1", "credit_type_id": "ct_1", "credits": 100, "expect": { "balance": 900 } } + + ] + + } + + ] + +} + +``` + + + +Rules: + + + +- All keys are `snake_case`. Vectors are plain JSON — no language-specific types. + +- **Virtual clock.** The run starts at a fixed virtual instant `t0`. Every `*_at_ms` field is an + + offset in milliseconds from `t0` (an absolute position on the virtual timeline, not relative to + + the current operation). The `advance_clock` operation moves the clock forward; nothing else does. + + Runners must execute vectors against a controllable clock (no wall time). + +- `backends` restricts which store backends the vector runs against; when omitted, the vector must + + pass against every backend the SDK ships (in-memory and Redis for Node). + +- `given.leases` are installed via the store's `replace` operation at `t0` (each install must + + return "written"). + +- Assertions are attached per-operation via `expect`. Final-state assertions are expressed as + + trailing read operations (`get_lease`, `reserved_credits`, `reservation_count`). + +- `expect.balance` / `expect.consumed` use JSON `null` for the "no / refused" result. + +- Reservation ids created by `check` operations are random; the vector names them via + + `save_reservation_as` and later operations reference them with `handle`. + + + +### Operations + + + +Store-level (exercise the lease store and reservation store directly): + + + +| op | fields | expect | + +| --- | --- | --- | + +| `advance_clock` | `ms` | — | + +| `replace_lease` | `lease_id`, `company_id`, `credit_type_id`, `granted_amount`, `expires_at_ms` | `written` (bool) | + +| `drop_lease` | `company_id`, `credit_type_id` | — | + +| `try_reserve` | `company_id`, `credit_type_id`, `credits` | `balance` (post-debit number, or `null`) | + +| `refund_lease` | `company_id`, `credit_type_id`, `credits`, `pin_lease_id`? | — | + +| `extend_lease` | `company_id`, `credit_type_id`, `granted_total`, `expires_at_ms`?, `pin_lease_id`? | — | + +| `get_lease` | `company_id`, `credit_type_id` | `exists`, `lease_id`?, `granted_amount`?, `local_remaining_credits`? | + +| `add_reservation` | `id`, `lease_id`, `company_id`, `credit_type_id`, `event_subtype`, `quantity_reserved`, `credits_reserved`, `consumption_rate`, `expires_at_ms` | — | + +| `consume_reservation` | `id` or `handle`, `credits`, `crash_before_refund`? (bool, one-shot) | `consumed` (number or `null`), `throws`? | + +| `get_reservation` | `id` or `handle` | `exists` | + +| `reserved_credits` | `company_id`, `credit_type_id` | `total` | + +| `reservation_count` | — | `count` | + +| `sweep_expired` | — | `swept` | + + + +Manager-level (exercise the lease manager with a scripted wire client): + + + +| op | fields | expect | + +| --- | --- | --- | + +| `acquire_if_needed` | `company_id`, `credit_type_id`, `server`? ( `{ "lease": {...} }` or `{ "error": "..." }` ), `install_during_wire`? (lease installed into the store while the wire call is in flight, emulating a sibling pod winning the race) | `lease_id` (or `null`), `wire_acquires` (cumulative count), `last_acquire_requested_amount`?, `released_lease_ids` (cumulative) | + +| `maybe_extend` | `company_id`, `credit_type_id`, `required_credits`?, `server`? ( `{ "lease": { "granted_total", "expires_at_ms" } }` or `{ "error": "..." }` ) | `wire_extends` (cumulative count), `last_extend_additional_amount`?, `last_extend_lease_id`? | + +| `release_all_local_leases` | — (in-memory backend only) | `released_lease_ids`, `remaining_slots` | + + + +Flow-level (exercise the full check/track orchestration with a scripted rules engine): + + + +| op | fields | expect | + +| --- | --- | --- | + +| `check` | `flag_key`, `company` (`{ id, credit_balances }`), `usage`, `event_subtype`?, `on_acquire_failure`?, `engine` (array of scripted engine results, consumed in call order), `server`? (as above), `save_reservation_as`? | `allowed`, `reason`?, `err`?, `has_reservation`, `reservation`? (field subset), `fallback_called`?, `engine_calls`? (per-call `{ credit_balance, credit_cost?, event_usage? }`; `credit_balance` may be the string `"max_safe_integer"`) | + +| `track` | `handle`, `actual_quantity` | `settled_locally`, `track` (`{ event, quantity, lease_id }`) | + + + +A scripted engine result is `{ "value": bool, "reason"?: string, "entitlement"?: { "value_type", + +"credit_id"?, "consumption_rate"?, "event_subtype"?, "feature_id"?, "feature_key"? } }`. The engine + +is an oracle: the vectors pin the *orchestration around* the rules engine (what it is called with, + +and what the SDK does with its answer), not the engine itself — the engine is shared WASM across + +SDKs and has its own tests. + + + +## Model overview + + + +Credit-metered features are gated client-side without a wire call per check. The SDK: + + + +1. **Leases** a tranche of credits from the server per `(company_id, credit_type_id)`. The server + + pre-debits the company balance by the granted amount; the SDK tracks a local view of how much + + of the tranche remains un-reserved (`local_remaining_credits`). + +2. **Reserves** `usage x consumption_rate` credits from the lease at `check()` time, atomically + + (check-and-debit). A successful, engine-approved check returns a *reservation handle*. + +3. **Settles** the reservation at `track()` time with the actual usage: the actually-consumed + + credits stay debited, the unspent slice is refunded to the lease, and a Track event bills the + + server (the server is the source of truth for real consumption). + + + +Everything client-side is *local bookkeeping against the leased tranche*. The server reconciles: + +an expired lease's unspent remainder is refunded to the company balance server-side, and Track + +events (keyed by `lease_id`) drive the authoritative consumption. + + + +Leases and reservations both expire: + + + +- A **lease** past its expiry must be treated as *released* — its local balance is stale (the + + server already refunded the remainder) and must never serve another reserve or be extended. + +- A **reservation** past its TTL is swept: removed from the table and its full hold refunded to + + the lease. Work that finishes after the sweep still bills the server (recovery emit) but does + + not re-debit the local lease. + + + +## State + + + +**Lease slot** — at most one lease per `(company_id, credit_type_id)` key: + + + +| field | meaning | + +| --- | --- | + +| `lease_id` | Server-issued id. | + +| `granted_amount` | Server-authoritative total granted to this lease (grows on extend). | + +| `local_remaining_credits` | Local view: granted minus outstanding holds/consumption. Initialized to `granted_amount` on install. | + +| `expires_at` | Expiry instant. Past it the lease is dead (see above). | + + + +**Reservation** — keyed by a unique id: + + + +| field | meaning | + +| --- | --- | + +| `id` | Unique (UUID in Node). | + +| `lease_id` | The lease the hold was carved from. Pins refunds. | + +| `company_id`, `credit_type_id` | Slot key. | + +| `event_subtype` | Event the settle will bill as. | + +| `quantity_reserved` | Caller-declared usage (event units). | + +| `credits_reserved` | `quantity_reserved x consumption_rate`. | + +| `consumption_rate` | Rate at reservation time. | + +| `expires_at` | Reservation TTL deadline (sweep target). | + +| `eval_ctx` | Company/user keys used at check time; threaded onto the Track event. | + + + +## Store operations + + + +These are the primitives both store backends (per-process in-memory; shared Redis) must implement + +with identical observable semantics. Each mutation must be atomic per slot/reservation (see + +[Invariants](#invariants-not-expressible-as-vectors)). + + + +### `replace(lease)` — install-if-not-live + + + +Install a fresh lease with `local_remaining_credits = granted_amount`, **only if** the slot is + +empty or the existing lease is expired *and carries a different `lease_id`*. If a *live* lease + +occupies the slot — even with a different `lease_id` (a sibling pod won the race) — leave it + +untouched (its already-debited balance wins) and report "kept". If an *expired* lease with the + +**same** `lease_id` occupies the slot (a stale acquire response for a lease the idempotent server + +also handed to a racing sibling, which may since have extended it), do not rewrite it either: + +rewriting would reset `local_remaining_credits` to the full grant and erase debits whose + +reservations are still open. Reconcile it like `extend` instead — granted to the incoming total + +(lower/equal totals are no-ops), expiry only forward, balance untouched — and report "kept". + +Returns written/kept so the caller can run the redundant-lease release logic (see manager). + + + +### `try_reserve(company, credit, credits)` — atomic check-and-debit + + + +- Reject (return `null`, touch nothing) if: no lease in the slot; the lease is **expired**; the + + remaining balance is `< credits`; or `credits` is not a finite non-negative number (NaN must + + never reach the arithmetic — it slips through every comparison and would poison the balance + + into approving everything). + +- Otherwise debit and return the **post-debit balance** (so the caller can derive the pre-debit + + figure as `returned + credits` without a racy follow-up read). + +- Reserving down to exactly 0 is allowed. + + + +### `refund(company, credit, credits, pin_lease_id?)` + + + +Add credits back to `local_remaining_credits`, **clamped at `granted_amount`**. No-op if + +`credits <= 0` or no lease is in the slot. When `pin_lease_id` is given, the refund applies + +**only if** the slot still holds that lease: a hold carved out of expired lease A must never + +inflate successor lease B — A's remainder (including this slice) was already refunded to the + +company balance server-side when A expired, so crediting B would double-count. + + + +### `extend(company, credit, granted_total, new_expires_at?, pin_lease_id?)` — reconcile to total + + + +After a remote extend, reconcile the slot to the **server-authoritative total**: + + + +- Compute `delta = granted_total - stored granted_amount` **atomically against the currently + + stored total** — never from a caller-held pre-wire-call read (two pods extending concurrently + + from the same stale read would each apply a delta and mint phantom credits). If `delta > 0`, + + set `granted_amount = granted_total` and add `delta` to `local_remaining_credits`. If + + `delta <= 0` (a total a sibling already applied, or a stale lower total) it is a **no-op** — + + applies converge in any order. + +- Expiry only ever moves **forward**: `new_expires_at` is applied only if later than the stored + + expiry, so an out-of-order apply cannot shorten a lease a sibling just extended. + +- When `pin_lease_id` is given and the slot holds a different lease, drop the whole extend + + (credits and expiry): the server granted the extension to the pinned lease; crediting a + + successor would mint credits the server refunds with the pinned lease at its expiry. + +- No-op if the slot is empty. + + + +### `drop(company, credit)` + + + +Remove the slot entry (after a remote release). Plain delete. + + + +### Reservation table: `add`, `get`, `consume`, `reserved_credits`, `sweep_expired` + + + +- `add(reservation)` — register. Idempotent on id. `add` does NOT debit the lease; the debit + + already happened in `try_reserve` (see [ordering](#bounded-leak-contract)). + +- `consume(id, credits_consumed)` — **exactly-once claim**: atomically remove the reservation + + from the table; if it was already gone (swept, or consumed by a racing caller) return `null` + + and touch nothing. On a successful claim, clamp `credits_consumed` to + + `[0, credits_reserved]`, refund `credits_reserved - clamped` to the lease (pinned to the + + reservation's `lease_id`), and return the clamped figure. The claim and the refund are two + + steps; the claim is the arbiter (see bounded-leak contract). + +- `reserved_credits(company, credit)` — sum of `credits_reserved` across open reservations for + + the slot. A reservation counts iff it is still in the table, so + + `local_remaining_credits + reserved_credits` stays exact between operations. + +- `sweep_expired(now)` — remove every reservation with `expires_at <= now` and refund its full + + hold to its lease (pinned to its `lease_id`; a stale-lease hold is dropped, not refunded). + + Returns the number swept. Runs on a background interval (`sweep_interval_ms`) in production; + + vectors call it explicitly. + + + +## Lease manager + + + +Owns the lease lifecycle against the server wire API (`acquire`, `extend`, `release`). + + + +### Acquire (`acquire_if_needed`) + + + +- If the slot holds a **live** lease, return it — no wire call. + +- Otherwise call the server: `requested_amount = lease_size`, `expires_at = now + + + lease_duration_ms`. An expired local entry is left in place for `replace` to overwrite + + atomically (deleting it first would open a race window against sibling pods; every reader + + re-guards on expiry anyway). + +- On response, `replace` the slot. If `replace` kept an existing lease (a sibling won, or the + + slot's expired row was reconciled in place): + + - If the installed lease has a **different id** than the one the server handed us, ours is a + + redundant hold nobody will draw on — release it (fire-and-forget; a failed release falls + + back to server-side lease expiry). + + - If the ids are the **same** (the server is idempotent for an active slot and handed the + + racing acquire the sibling's lease back), release **nothing** — releasing would pull the + + shared lease out from under every sibling. + + - If the slot reads empty (expired in the gap), also release nothing. + + - Either way, return whatever the slot now holds. + +- Wire or store failure: return "no lease" (never throw) — the caller routes it through + + fail-open/fail-closed. + +- Per-process single-flight per slot: concurrent callers share one in-flight wire call + + (best-effort; duplicates are absorbed by the idempotent server + `replace`). + + + +### Extend (`maybe_extend`) + + + +Triggered when EITHER: + + + +- `local_remaining_credits / max(granted_amount, 1) <= low_water_mark` (steady-state refresh), or + +- the caller passes `required_credits` and `local_remaining_credits < required_credits` (a check + + just failed a reserve of that size — extend opportunistically). + + + +Rules: + + + +- **Never extend an expired lease** — the server treats it as released; the right move is a fresh + + acquire on the next check. + +- Wire body: `additional_amount = max(lease_size, required_credits - local_remaining_credits)`. + + Sizing to the shortfall matters: a single check needing more than `remaining + lease_size` + + would otherwise fail its post-extend retry forever regardless of server balance. + + `expires_at = now + lease_duration_ms`. + +- On response, reconcile via the store's `extend` with the server's **total** and new expiry, + + **pinned** to the extended lease's id. + +- Failures resolve to "no lease" without throwing (often fire-and-forget). + +- Per-process single-flight per slot, kept separate from acquire's (an in-flight extend must not + + satisfy an acquire, or vice versa). + + + +### Release on close (`release_all_local_leases`) + + + +Only for a **per-process (in-memory) store**, whose leases are exclusively this process's: + +release every live lease over the wire (returning the unspent remainder to the company balance + +immediately) and drop it locally; **skip expired** leases (already swept server-side). A shared + +(Redis) store must never do this — sibling pods still draw on those leases. Best-effort: + +failures fall back to server-side expiry. + + + +## Check flow + + + +`check(eval_ctx, flag_key, { usage, event_subtype?, on_acquire_failure?, ... })` — the + +lease-gated feature check. Fallback = the plain (non-lease) flag check, which has its own + +degradation story; when the flow "falls back", no reservation is issued and no lease state is + +touched beyond what already happened. + + + +Guards, in order: + + + +1. `usage` missing → plain check (lease path not requested). + +2. `usage` not a finite non-negative number → resolve **statically** by `on_acquire_failure` + + (deny for fail-closed; blanket allow for fail-open, reason `invalid_usage`). The value must + + never reach the stores. + +3. `usage == 0` → nothing to reserve; fall back to the plain check (no 0-credit reservation). + +4. No datastream / cached flag / resolvable company (or named user) → fall back. + + + +Then: + + + +5. **Entitlement probe.** Run the rules engine once against the company's *real* balance — no + + substitution, no credit-cost preflight (a preflight against the lease-depleted server balance + + could fail the credit condition and hide the entitlement being probed for). Read the matched + + entitlement's shape: + + - Not credit-metered (`value_type != "credit"`: boolean/override grant, numeric allocation, + + unlimited, or not entitled) → **fall back**, no lease traffic at all. + + - Credit entitlement missing `credit_id`, a positive `consumption_rate`, or a resolvable + + `event_subtype` (caller's explicit subtype wins over the entitlement's) → fall back. + + - Probe error → fall back (it is a resolution step, not the gate). + +6. `credit_cost = usage x consumption_rate`. + +7. **Acquire** a lease for `(company, credit_id)`. Failure → [failure handling](#failure-handling) + + with reason `lease_acquire_failed`. + +8. **Reserve** `credit_cost` via `try_reserve`. On refusal, opportunistically + + `maybe_extend(required_credits = credit_cost)` (awaited) and retry the reserve **once**. + + Still refused → failure handling, reason `insufficient_lease_balance`. Store error → + + failure handling, reason `lease_store_error`. + +9. **Record the reservation** (TTL = `reservation_ttl_ms` from now) — *after* the debit, *before* + + the engine gate. If persisting fails, undo the debit (claim-and-refund; direct refund if + + nothing persisted; both pinned to the lease) and go to failure handling + + (`lease_store_error`). If even the undo fails, accept the bounded leak. + +10. **Engine gate.** Re-run the engine against a company snapshot whose + + `credit_balances[credit_id]` is substituted with the **pre-reservation** local balance + + (post-debit balance returned by `try_reserve` + `credit_cost` — exact as of the debit, no + + read race), passing `credit_cost = { credit_id: credit_cost }` so the engine evaluates + + `pre_reservation - credit_cost >= 0` — the same arithmetic `try_reserve` just enforced, plus + + every non-credit rule (plan targeting, overrides). + + - Engine **allows** → keep the hold; return `{ allowed: true, reservation }`. Fire-and-forget + + a watermark-driven `maybe_extend`. + + - Engine **denies** → cancel the reservation (claim + full refund) and return + + `{ allowed: false }` with the engine's reason. + + - Engine **errors** → cancel the reservation and resolve **statically** by mode (the engine + + itself is down, so no fail-open re-evaluation is possible). + + + +### Failure handling + + + +Every can't-gate outcome (acquire failed, store unreachable, lease exhausted) funnels through the + +configured `on_acquire_failure` mode (default **fail-closed**): + + + +- **fail-closed** → `{ allowed: false }`, reason = the failure reason. No reservation. + +- **fail-open** → *err on the side of assuming the credits are there*, **not** blanket allow: + + re-run the engine with the credit balance substituted to an effectively unlimited value + + (`MAX_SAFE_INTEGER` in Node) and the caller's usage preflight threaded through. Plan + + targeting, overrides, and every non-credit condition still apply — a company that is not + + entitled stays **denied** even with the lease backend down. No reservation is issued either + + way; `err` carries the failure reason. Only if that evaluation itself errors does the SDK + + fall back to a blanket allow. + + + +## Track / settle flow + + + +`track_with_reservation(reservation, actual_quantity)` settles a reservation: + + + +1. `credits = actual_quantity x reservation.consumption_rate`. + +2. `consume(reservation.id, credits)`: + + - **Settled locally** (claim succeeded): the clamped consumed slice stays debited; the unspent + + slice is refunded to the lease (pinned). + + - **Not settled** (`null`: already swept after TTL, already consumed, or store unreachable): + + local lease state is untouched — if the sweeper already refunded the full hold, nothing + + re-debits the consumed slice, so the local balance reads **high** until the lease rolls + + over. This is why `reservation_ttl_ms` should exceed the longest expected gap between + + `check()` and `track_with_reservation()`. + +3. Either way, emit the Track event built from the **caller-held handle** (not the store): + + `event = event_subtype`, `quantity = actual_quantity` (the *unclamped* actual — the server is + + the source of truth for real consumption; only local bookkeeping clamps to the reserved + + amount), `lease_id = reservation.lease_id` (routes the server-side consumption through the + + lease's sub-ledger instead of double-debiting the pre-debited grant), plus the reservation's + + `eval_ctx` company/user and any caller traits. + +4. The Track carries a deterministic idempotency key derived from the reservation id + + (`"lease-reservation:" + reservation.id` in Node); the server dedupes by it for 24h, so a + + recovery emit racing the normal emit, or an accidental double settle, collapses to one billed + + event across pods and restarts. + +5. Guard: a non-finite or negative `actual_quantity` skips the settle entirely (no store call, no + + event) — the untouched reservation expires at its TTL and the sweeper refunds the full hold. + + + +## Configuration knobs + + + +| knob (vector key) | Node name | default | meaning | + +| --- | --- | --- | --- | + +| `lease_duration_ms` | `defaultLeaseDuration` | 300 000 (5 min) | Lease lifetime requested at acquire/extend (`expires_at = now + duration`). | + +| `reservation_ttl_ms` | `defaultReservationTTL` | 60 000 (60 s) | Reservation lifetime; the sweep deadline. Size above the longest expected check→track gap. | + +| `lease_size` | `defaultLeaseSize` | 10 000 | Credits requested per acquire, and the minimum extend tranche. | + +| `low_water_mark` | `lowWaterMark` | 0.25 | Remaining/granted ratio at or below which a background extend is kicked off. | + +| `sweep_interval_ms` | `sweepIntervalMs` | 1 000 | Expired-reservation sweep cadence. | + +| — | `onAcquireFailure` | `fail-closed` | Per-check failure mode (see check flow). | + + + +Per-credit-type overrides of the first four are supported (keyed by credit type id); resolution is + +override → client config → default. + + + +## Bounded-leak contract + + + +The flow deliberately orders its two-step transitions so that a process crash between steps leaks + +*locally held credits* (which the server reclaims at lease expiry) rather than enabling a + +double-spend. The invariant direction is always: **the debit/claim is durable first; the + +record/refund may be lost.** + + + +| # | crash window | what leaks | bound | reclaimed by | must NOT happen | + +| --- | --- | --- | --- | --- | --- | + +| 1 | after `try_reserve` (debit), before `add` (record) | the debited hold — invisible to the reservation table, so the sweeper can never refund it | `credits_reserved` of that one check | lease expiry: the expired balance is never served again, and the server refunds the whole grant; the successor lease installs at full grant | a reservation record without a debit (a later consume would refund credits never held → double-spend). Vectors pin that the debit lands strictly before the record. | + +| 2 | inside `consume`: after the claim, before the refund | the unspent slice of that reservation | `credits_reserved` of that one reservation | lease expiry (same mechanism) | a double refund: the claim is exactly-once, so a retried settle or a sweeper finds nothing to claim and refunds nothing | + +| 3 | (Redis only) after the claim, before index cleanup | nothing (bookkeeping only): the per-slot reserved-credits index transiently over-counts | one index field | the sweeper reconciles the orphaned index entry — **without refunding** (without the claimed record, exactly-once cannot be arbitrated across racing sweepers) | a refund driven by an index entry alone | + + + +Additional pinned properties: + + + +- A leak never survives its lease: after lease expiry the stale balance is refused + + (`try_reserve → null`) and a successor lease restores the full grant. + +- A retried check after a window-1 crash settles independently: its own slice refunds exactly + + once; the leaked slice never refunds. + +- A late retried settle after a window-2 crash (even after a successor lease is installed) + + refunds nothing into the successor. + + + +## Invariants not expressible as vectors + + + +These hold in the reference implementation but need concurrency, wall clocks, or non-JSON values + +to demonstrate; ports must uphold them and should test them natively. + + + +1. **Per-slot atomicity.** `replace`, `try_reserve`, `refund`, `extend` are atomic per lease + + slot; `consume`'s claim is atomic per reservation. In-memory: per-key mutual exclusion. Redis: + + single-key Lua scripts (single-key keeps them Redis-Cluster-safe; the refund to the lease hash + + is deliberately a separate single-key step, never a multi-key script — see leak window 2/3). + +2. **Server-clock expiry (shared backend).** With a shared store, lease expiry must be decided + + against the *store's* clock (Redis `TIME`), not the calling process's — pods with skewed + + clocks must agree on liveness. Backend rows carry a TTL grace window past `expires_at` + + (60 s lease / 30 s reservation in Node) so the sweeper can still read them; expired-but-not- + + evicted rows must still refuse reserves. + +3. **NaN/precision guards.** Non-finite or negative amounts are rejected at every boundary + + (`usage`, `try_reserve`, `actual_quantity`) — JSON cannot encode NaN, so vectors only cover + + the negative case. Fractional credit amounts are legal throughout (rates like 0.1); Redis + + stores balances as strings to avoid integer truncation. + +4. **Single-flight.** Per-process, per-slot single-flight for acquire and for extend, tracked + + separately. Best-effort only: duplicate wire calls are safe (idempotent server + keep-first + + `replace` + reconcile-to-total `extend`). + +5. **Concurrent cross-pod extends converge.** Two pods extending from the same stale read must + + not double-count — guaranteed by reconcile-to-total computed inside the store (the sequential + + out-of-order-totals vector pins the arithmetic; the concurrent schedule needs a race). + +6. **Idempotent billing.** The Track idempotency key is deterministic from the reservation id; + + the server dedupes for 24h. Double settles and recovery emits collapse to one billed event. + +7. **Background sweep loop.** `start_sweep`/`stop` run `sweep_expired` on an interval; timers + + must not keep the process alive. Vectors call `sweep_expired` explicitly instead. + +8. **Fire-and-forget never rejects.** `acquire_if_needed`, `maybe_extend`, and release paths + + resolve (to "no lease") on failure rather than rejecting — they are often unawaited. + +9. **Offline/unconfigured degradation.** Lease config absent → `check` is a plain flag check; + + `track_with_reservation` on an unconfigured client still emits the billing event with the + + `lease_id` and idempotency key intact. + diff --git a/conformance/vectors/check-flow.json b/conformance/vectors/check-flow.json + new file mode 100644 + index 0000000..eff9990 + --- /dev/null + +++ b/conformance/vectors/check-flow.json + @@ -0,0 +1,527 @@ + +{ + + "category": "check_flow", + + "vectors": [ + + { + + "name": "check_happy_path_gates_on_pre_reservation_balance", + + "description": "A lease-bearing check: probe against the real balance (no substitution, no credit cost), reserve usage x rate from the lease, then gate the engine on the PRE-reservation local balance with credit_cost — the same arithmetic the atomic reserve just enforced. The hold sticks only because the engine allowed.", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + }, + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 300000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "check", + + "flag_key": "inference", + + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + + "usage": 10, + + "event_subtype": "inference_tokens", + + "save_reservation_as": "r1", + + "engine": [ + + { + + "value": true, + + "reason": "probe", + + "entitlement": { + + "value_type": "credit", + + "credit_id": "ct_1", + + "consumption_rate": 10, + + "event_subtype": "inference_tokens" + + } + + }, + + { "value": true, "reason": "ok" } + + ], + + "expect": { + + "allowed": true, + + "has_reservation": true, + + "reservation": { + + "lease_id": "lse_1", + + "credit_type_id": "ct_1", + + "event_subtype": "inference_tokens", + + "quantity_reserved": 10, + + "credits_reserved": 100, + + "consumption_rate": 10 + + }, + + "engine_calls": [{ "credit_balance": 5000 }, { "credit_balance": 1000, "credit_cost": 100 }] + + } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 900 } + + }, + + { "op": "reserved_credits", "company_id": "co_1", "credit_type_id": "ct_1", "expect": { "total": 100 } } + + ] + + }, + + { + + "name": "check_denied_by_engine_cancels_the_hold", + + "description": "When the gate evaluation denies, the reservation made before the eval is cancelled: claimed and fully refunded, leaving no hold and no reservation.", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + }, + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 300000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "check", + + "flag_key": "inference", + + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + + "usage": 10, + + "event_subtype": "inference_tokens", + + "engine": [ + + { + + "value": true, + + "reason": "probe", + + "entitlement": { + + "value_type": "credit", + + "credit_id": "ct_1", + + "consumption_rate": 10, + + "event_subtype": "inference_tokens" + + } + + }, + + { "value": false, "reason": "denied_by_targeting" } + + ], + + "expect": { "allowed": false, "reason": "denied_by_targeting", "has_reservation": false } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 1000 } + + }, + + { "op": "reserved_credits", "company_id": "co_1", "credit_type_id": "ct_1", "expect": { "total": 0 } }, + + { "op": "reservation_count", "expect": { "count": 0 } } + + ] + + }, + + { + + "name": "check_acquire_failure_fail_closed_denies", + + "description": "fail-closed (the default): when no lease can be acquired the check denies outright with the failure reason; no reservation is issued.", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + } + + }, + + "operations": [ + + { + + "op": "check", + + "flag_key": "inference", + + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + + "usage": 10, + + "event_subtype": "inference_tokens", + + "on_acquire_failure": "fail-closed", + + "server": { "acquire": { "error": "wire down" } }, + + "engine": [ + + { + + "value": true, + + "reason": "probe", + + "entitlement": { + + "value_type": "credit", + + "credit_id": "ct_1", + + "consumption_rate": 10, + + "event_subtype": "inference_tokens" + + } + + } + + ], + + "expect": { + + "allowed": false, + + "reason": "lease_acquire_failed", + + "err": "lease_acquire_failed", + + "has_reservation": false + + } + + }, + + { "op": "reservation_count", "expect": { "count": 0 } } + + ] + + }, + + { + + "name": "check_acquire_failure_fail_open_reevaluates", + + "description": "fail-open is NOT blanket allow: the engine re-runs with the credit balance substituted to an effectively unlimited value and the caller's usage preflight threaded through, so non-credit rules still apply. Here they pass, so the check allows — with the failure recorded in err and no reservation.", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + } + + }, + + "operations": [ + + { + + "op": "check", + + "flag_key": "inference", + + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + + "usage": 10, + + "event_subtype": "inference_tokens", + + "on_acquire_failure": "fail-open", + + "server": { "acquire": { "error": "wire down" } }, + + "engine": [ + + { + + "value": true, + + "reason": "probe", + + "entitlement": { + + "value_type": "credit", + + "credit_id": "ct_1", + + "consumption_rate": 10, + + "event_subtype": "inference_tokens" + + } + + }, + + { "value": true, "reason": "evaluated" } + + ], + + "expect": { + + "allowed": true, + + "reason": "evaluated (lease_acquire_failed_fail_open)", + + "err": "lease_acquire_failed", + + "has_reservation": false, + + "engine_calls": [ + + { "credit_balance": 5000 }, + + { + + "credit_balance": "max_safe_integer", + + "event_usage": { "event_subtype": "inference_tokens", "quantity": 10 } + + } + + ] + + } + + }, + + { "op": "reservation_count", "expect": { "count": 0 } } + + ] + + }, + + { + + "name": "check_fail_open_still_denies_when_rules_deny", + + "description": "fail-open with a denying rules evaluation stays denied: substituting an unlimited balance only bypasses the credit gate, never plan targeting or overrides.", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + } + + }, + + "operations": [ + + { + + "op": "check", + + "flag_key": "inference", + + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + + "usage": 10, + + "event_subtype": "inference_tokens", + + "on_acquire_failure": "fail-open", + + "server": { "acquire": { "error": "wire down" } }, + + "engine": [ + + { + + "value": true, + + "reason": "probe", + + "entitlement": { + + "value_type": "credit", + + "credit_id": "ct_1", + + "consumption_rate": 10, + + "event_subtype": "inference_tokens" + + } + + }, + + { "value": false, "reason": "not_targeted" } + + ], + + "expect": { + + "allowed": false, + + "reason": "not_targeted (lease_acquire_failed_fail_open)", + + "err": "lease_acquire_failed", + + "has_reservation": false + + } + + } + + ] + + }, + + { + + "name": "check_insufficient_lease_extends_and_retries", + + "description": "A reserve refusal triggers an awaited opportunistic extend sized to cover the request (required_credits = credit_cost), then exactly one reserve retry. On success the check proceeds normally, gating on the post-extend pre-reservation balance.", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + }, + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 300000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 950, + + "expect": { "balance": 50 } + + }, + + { + + "op": "check", + + "flag_key": "inference", + + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + + "usage": 10, + + "event_subtype": "inference_tokens", + + "server": { "extend": { "lease": { "granted_total": 2000, "expires_at_ms": 600000 } } }, + + "save_reservation_as": "r1", + + "engine": [ + + { + + "value": true, + + "reason": "probe", + + "entitlement": { + + "value_type": "credit", + + "credit_id": "ct_1", + + "consumption_rate": 10, + + "event_subtype": "inference_tokens" + + } + + }, + + { "value": true, "reason": "ok" } + + ], + + "expect": { + + "allowed": true, + + "has_reservation": true, + + "wire_extends": 1, + + "last_extend_additional_amount": 1000, + + "engine_calls": [{ "credit_balance": 5000 }, { "credit_balance": 1050, "credit_cost": 100 }] + + } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "granted_amount": 2000, "local_remaining_credits": 950 } + + } + + ] + + }, + + { + + "name": "check_insufficient_lease_after_failed_extend_resolves_by_mode", + + "description": "When the opportunistic extend fails and the retry is still refused, the check resolves through the failure mode (fail-closed here) with reason insufficient_lease_balance, leaving the lease balance untouched.", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + }, + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 300000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 950, + + "expect": { "balance": 50 } + + }, + + { + + "op": "check", + + "flag_key": "inference", + + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + + "usage": 10, + + "event_subtype": "inference_tokens", + + "on_acquire_failure": "fail-closed", + + "server": { "extend": { "error": "wire down" } }, + + "engine": [ + + { + + "value": true, + + "reason": "probe", + + "entitlement": { + + "value_type": "credit", + + "credit_id": "ct_1", + + "consumption_rate": 10, + + "event_subtype": "inference_tokens" + + } + + } + + ], + + "expect": { + + "allowed": false, + + "reason": "insufficient_lease_balance", + + "err": "insufficient_lease_balance", + + "has_reservation": false + + } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 50 } + + }, + + { "op": "reservation_count", "expect": { "count": 0 } } + + ] + + }, + + { + + "name": "check_falls_back_without_usable_credit_entitlement", + + "description": "A non-credit matched entitlement (boolean/override/numeric/unlimited/not entitled) or an incomplete credit entitlement (no positive consumption rate) defers to the plain check: no lease traffic, no reservation.", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + } + + }, + + "operations": [ + + { + + "op": "check", + + "flag_key": "inference", + + "company": { "id": "co_1", "credit_balances": {} }, + + "usage": 10, + + "event_subtype": "inference_tokens", + + "engine": [{ "value": true, "reason": "probe", "entitlement": { "value_type": "boolean" } }], + + "expect": { "fallback_called": true, "reason": "fallback", "has_reservation": false } + + }, + + { + + "op": "check", + + "flag_key": "inference", + + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + + "usage": 10, + + "event_subtype": "inference_tokens", + + "engine": [ + + { + + "value": true, + + "reason": "probe", + + "entitlement": { + + "value_type": "credit", + + "credit_id": "ct_1", + + "consumption_rate": 0, + + "event_subtype": "inference_tokens" + + } + + } + + ], + + "expect": { "fallback_called": true, "reason": "fallback", "has_reservation": false } + + }, + + { "op": "reservation_count", "expect": { "count": 0 } } + + ] + + }, + + { + + "name": "check_zero_usage_falls_back", + + "description": "usage = 0 means nothing to reserve: the check defers to the plain (preflight-threaded) check instead of issuing a no-op 0-credit reservation.", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + }, + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 300000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "check", + + "flag_key": "inference", + + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + + "usage": 0, + + "event_subtype": "inference_tokens", + + "engine": [], + + "expect": { "fallback_called": true, "reason": "fallback", "has_reservation": false } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 1000 } + + } + + ] + + }, + + { + + "name": "check_invalid_usage_resolves_statically_by_mode", + + "description": "A negative (or non-finite) usage must never reach the stores; the check resolves statically by mode without any engine evaluation: deny for fail-closed, blanket allow for fail-open, reason invalid_usage either way.", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + }, + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 300000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "check", + + "flag_key": "inference", + + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + + "usage": -5, + + "event_subtype": "inference_tokens", + + "on_acquire_failure": "fail-closed", + + "engine": [], + + "expect": { + + "allowed": false, + + "reason": "invalid_usage", + + "err": "invalid_usage", + + "has_reservation": false, + + "engine_calls": [] + + } + + }, + + { + + "op": "check", + + "flag_key": "inference", + + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + + "usage": -5, + + "event_subtype": "inference_tokens", + + "on_acquire_failure": "fail-open", + + "engine": [], + + "expect": { + + "allowed": true, + + "reason": "invalid_usage_fail_open", + + "err": "invalid_usage", + + "has_reservation": false, + + "engine_calls": [] + + } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 1000 } + + } + + ] + + } + + ] + +} + diff --git a/conformance/vectors/crash-windows.json b/conformance/vectors/crash-windows.json + new file mode 100644 + index 0000000..c8ee05b + --- /dev/null + +++ b/conformance/vectors/crash-windows.json + @@ -0,0 +1,271 @@ + +{ + + "category": "crash_window", + + "vectors": [ + + { + + "name": "debit_without_record_leaks_bounded", + + "description": "Crash window 1 (debit-then-add): the atomic debit landed but the reservation record never did. The leak is exactly the reserved amount; the sweeper can never refund a hold that was never recorded.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 100, + + "expect": { "balance": 900 } + + }, + + { "op": "reserved_credits", "company_id": "co_1", "credit_type_id": "ct_1", "expect": { "total": 0 } }, + + { "op": "advance_clock", "ms": 55000 }, + + { "op": "sweep_expired", "expect": { "swept": 0 } }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 900 } + + } + + ] + + }, + + { + + "name": "debit_leak_reclaimed_at_lease_expiry", + + "description": "Crash window 1 recovery: the leaked balance is never served after lease expiry, and the successor lease installs at the full grant — the leak does not outlive the lease.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 100, + + "expect": { "balance": 900 } + + }, + + { "op": "advance_clock", "ms": 60001 }, + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 1, + + "expect": { "balance": null } + + }, + + { + + "op": "replace_lease", + + "lease_id": "lse_2", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 180000, + + "expect": { "written": true } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 1000 } + + } + + ] + + }, + + { + + "name": "retried_check_after_debit_leak_settles_once", + + "description": "A retry after a window-1 crash is a fresh check with a fresh reservation: its unspent slice refunds exactly once; the leaked slice never refunds — not on a repeat consume, not on a sweep.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 3600000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 100, + + "expect": { "balance": 900 } + + }, + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 100, + + "expect": { "balance": 800 } + + }, + + { + + "op": "add_reservation", + + "id": "res_retry", + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "event_subtype": "inference_tokens", + + "quantity_reserved": 10, + + "credits_reserved": 100, + + "consumption_rate": 10, + + "expires_at_ms": 60000 + + }, + + { "op": "consume_reservation", "id": "res_retry", "credits": 40, "expect": { "consumed": 40 } }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 860 } + + }, + + { "op": "consume_reservation", "id": "res_retry", "credits": 40, "expect": { "consumed": null } }, + + { "op": "advance_clock", "ms": 70000 }, + + { "op": "sweep_expired", "expect": { "swept": 0 } }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 860 } + + } + + ] + + }, + + { + + "name": "crash_before_refund_claim_is_durable", + + "description": "Crash window 2 (consume-then-refund): the claim survives the crash, so the reservation is gone everywhere and nothing can double-spend; the unspent slice's refund is lost, bounded by credits_reserved. A retried settle neither re-claims nor double-refunds, and the sweeper cannot refund a claimed reservation.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 3600000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 100, + + "expect": { "balance": 900 } + + }, + + { + + "op": "add_reservation", + + "id": "res_1", + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "event_subtype": "inference_tokens", + + "quantity_reserved": 10, + + "credits_reserved": 100, + + "consumption_rate": 10, + + "expires_at_ms": 60000 + + }, + + { + + "op": "consume_reservation", + + "id": "res_1", + + "credits": 30, + + "crash_before_refund": true, + + "expect": { "throws": true } + + }, + + { "op": "get_reservation", "id": "res_1", "expect": { "exists": false } }, + + { "op": "reserved_credits", "company_id": "co_1", "credit_type_id": "ct_1", "expect": { "total": 0 } }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 900 } + + }, + + { "op": "consume_reservation", "id": "res_1", "credits": 30, "expect": { "consumed": null } }, + + { "op": "advance_clock", "ms": 70000 }, + + { "op": "sweep_expired", "expect": { "swept": 0 } }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 900 } + + } + + ] + + }, + + { + + "name": "crash_before_refund_reclaimed_at_lease_expiry", + + "description": "Crash window 2 recovery: after the lease expires and a successor takes the slot at full grant, a very late retried settle of the crashed reservation must not leak the lost refund into the successor.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 100, + + "expect": { "balance": 900 } + + }, + + { + + "op": "add_reservation", + + "id": "res_1", + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "event_subtype": "inference_tokens", + + "quantity_reserved": 10, + + "credits_reserved": 100, + + "consumption_rate": 10, + + "expires_at_ms": 60000 + + }, + + { + + "op": "consume_reservation", + + "id": "res_1", + + "credits": 30, + + "crash_before_refund": true, + + "expect": { "throws": true } + + }, + + { "op": "advance_clock", "ms": 60001 }, + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 1, + + "expect": { "balance": null } + + }, + + { + + "op": "replace_lease", + + "lease_id": "lse_2", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 180000, + + "expect": { "written": true } + + }, + + { "op": "consume_reservation", "id": "res_1", "credits": 0, "expect": { "consumed": null } }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "lease_id": "lse_2", "local_remaining_credits": 1000 } + + } + + ] + + } + + ] + +} + diff --git a/conformance/vectors/expiry.json b/conformance/vectors/expiry.json + new file mode 100644 + index 0000000..e1f7e2c + --- /dev/null + +++ b/conformance/vectors/expiry.json + @@ -0,0 +1,198 @@ + +{ + + "category": "expiry", + + "vectors": [ + + { + + "name": "expired_lease_never_serves_reserves", + + "description": "Past its expiry a lease's balance is stale (the server refunded the grant): reserves are refused even with ample local balance.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 100, + + "expect": { "balance": 900 } + + }, + + { "op": "advance_clock", "ms": 60001 }, + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 1, + + "expect": { "balance": null } + + } + + ] + + }, + + { + + "name": "successor_after_expiry_restores_full_grant", + + "description": "A successor lease installed over an expired slot starts at its full grant — nothing from the expired lease (debits or leaks) carries over.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 700, + + "expect": { "balance": 300 } + + }, + + { "op": "advance_clock", "ms": 60001 }, + + { + + "op": "replace_lease", + + "lease_id": "lse_2", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 180000, + + "expect": { "written": true } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "lease_id": "lse_2", "local_remaining_credits": 1000 } + + } + + ] + + }, + + { + + "name": "sweep_refunds_expired_holds_only", + + "description": "The sweeper refunds an expired reservation's full hold to its lease and leaves live reservations untouched.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 300000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 300, + + "expect": { "balance": 700 } + + }, + + { + + "op": "add_reservation", + + "id": "res_short", + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "event_subtype": "inference_tokens", + + "quantity_reserved": 10, + + "credits_reserved": 100, + + "consumption_rate": 10, + + "expires_at_ms": 10000 + + }, + + { + + "op": "add_reservation", + + "id": "res_long", + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "event_subtype": "inference_tokens", + + "quantity_reserved": 20, + + "credits_reserved": 200, + + "consumption_rate": 10, + + "expires_at_ms": 200000 + + }, + + { "op": "sweep_expired", "expect": { "swept": 0 } }, + + { "op": "advance_clock", "ms": 10001 }, + + { "op": "sweep_expired", "expect": { "swept": 1 } }, + + { "op": "get_reservation", "id": "res_short", "expect": { "exists": false } }, + + { "op": "get_reservation", "id": "res_long", "expect": { "exists": true } }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 800 } + + }, + + { "op": "reserved_credits", "company_id": "co_1", "credit_type_id": "ct_1", "expect": { "total": 200 } } + + ] + + }, + + { + + "name": "sweep_of_stale_lease_hold_does_not_inflate_successor", + + "description": "Sweeping (or consuming) a reservation carved from an expired lease refunds nothing into the successor lease occupying the slot: the hold is pinned to its originating lease_id.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 100, + + "expect": { "balance": 900 } + + }, + + { + + "op": "add_reservation", + + "id": "res_stale", + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "event_subtype": "inference_tokens", + + "quantity_reserved": 10, + + "credits_reserved": 100, + + "consumption_rate": 10, + + "expires_at_ms": 90000 + + }, + + { "op": "advance_clock", "ms": 60001 }, + + { + + "op": "replace_lease", + + "lease_id": "lse_2", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 300000, + + "expect": { "written": true } + + }, + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 200, + + "expect": { "balance": 800 } + + }, + + { "op": "advance_clock", "ms": 30000 }, + + { "op": "sweep_expired", "expect": { "swept": 1 } }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "lease_id": "lse_2", "local_remaining_credits": 800 } + + } + + ] + + } + + ] + +} + diff --git a/conformance/vectors/lease-lifecycle.json b/conformance/vectors/lease-lifecycle.json + new file mode 100644 + index 0000000..085b5fa + --- /dev/null + +++ b/conformance/vectors/lease-lifecycle.json + @@ -0,0 +1,447 @@ + +{ + + "category": "lease_lifecycle", + + "vectors": [ + + { + + "name": "replace_installs_full_grant", + + "description": "A fresh install initializes local_remaining_credits to the full granted amount.", + + "operations": [ + + { + + "op": "replace_lease", + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 60000, + + "expect": { "written": true } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { + + "exists": true, + + "lease_id": "lse_1", + + "granted_amount": 1000, + + "local_remaining_credits": 1000 + + } + + } + + ] + + }, + + { + + "name": "replace_keeps_live_lease_even_with_different_id", + + "description": "A live lease occupying the slot wins over any replace — even one carrying a different lease_id (a sibling raced this acquire). Its already-debited balance is preserved.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 400, + + "expect": { "balance": 600 } + + }, + + { + + "op": "replace_lease", + + "lease_id": "lse_2", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 5000, + + "expires_at_ms": 120000, + + "expect": { "written": false } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { + + "exists": true, + + "lease_id": "lse_1", + + "granted_amount": 1000, + + "local_remaining_credits": 600 + + } + + } + + ] + + }, + + { + + "name": "replace_reconciles_expired_slot_with_same_id", + + "description": "A stale acquire response for the SAME lease landing over its own expired local row must not reinstall it: that would reset local_remaining_credits and erase debits whose reservations are still open. The row is reconciled like an extend (granted to total, expiry forward, balance untouched) and reported as kept.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 400, + + "expect": { "balance": 600 } + + }, + + { "op": "advance_clock", "ms": 60001 }, + + { + + "op": "replace_lease", + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1500, + + "expires_at_ms": 120000, + + "expect": { "written": false } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { + + "exists": true, + + "lease_id": "lse_1", + + "granted_amount": 1500, + + "local_remaining_credits": 1100 + + } + + }, + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 100, + + "expect": { "balance": 1000 } + + } + + ] + + }, + + { + + "name": "replace_overwrites_expired_lease", + + "description": "An expired lease does not block the slot: replace overwrites it atomically and restores the full new grant.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 400, + + "expect": { "balance": 600 } + + }, + + { "op": "advance_clock", "ms": 60001 }, + + { + + "op": "replace_lease", + + "lease_id": "lse_2", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 180000, + + "expect": { "written": true } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "lease_id": "lse_2", "local_remaining_credits": 1000 } + + } + + ] + + }, + + { + + "name": "try_reserve_insufficient_and_boundary", + + "description": "A reserve larger than the remaining balance is refused and touches nothing; reserving down to exactly zero is allowed; negative amounts are always refused.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 100, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 101, + + "expect": { "balance": null } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 100 } + + }, + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": -1, + + "expect": { "balance": null } + + }, + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 100, + + "expect": { "balance": 0 } + + }, + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 1, + + "expect": { "balance": null } + + }, + + { + + "op": "try_reserve", + + "company_id": "co_9", + + "credit_type_id": "ct_1", + + "credits": 1, + + "expect": { "balance": null } + + } + + ] + + }, + + { + + "name": "refund_clamped_at_granted_amount", + + "description": "A refund can never push the local balance above the granted amount.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 100, + + "expect": { "balance": 900 } + + }, + + { "op": "refund_lease", "company_id": "co_1", "credit_type_id": "ct_1", "credits": 500 }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 1000 } + + } + + ] + + }, + + { + + "name": "refund_pinned_to_lease_id_dropped_on_successor", + + "description": "A refund pinned to an expired lease's id must not inflate the successor lease occupying the slot — the expired lease's remainder was already returned to the company balance server-side. An unpinned refund still applies.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_2", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 200, + + "expect": { "balance": 800 } + + }, + + { + + "op": "refund_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 100, + + "pin_lease_id": "lse_1" + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 800 } + + }, + + { + + "op": "refund_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 100, + + "pin_lease_id": "lse_2" + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 900 } + + } + + ] + + }, + + { + + "name": "extend_reconciles_to_total_and_converges", + + "description": "Extend applies the server-authoritative TOTAL: the delta is computed against the stored total, so a repeated or stale-lower total is a no-op and out-of-order applies converge without minting credits.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 300000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 800, + + "expect": { "balance": 200 } + + }, + + { + + "op": "extend_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_total": 3000, + + "expires_at_ms": 300000 + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "granted_amount": 3000, "local_remaining_credits": 2200 } + + }, + + { + + "op": "extend_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_total": 3000, + + "expires_at_ms": 300000 + + }, + + { + + "op": "extend_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_total": 2000, + + "expires_at_ms": 300000 + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "granted_amount": 3000, "local_remaining_credits": 2200 } + + } + + ] + + }, + + { + + "name": "extend_expiry_only_moves_forward", + + "description": "An extend carrying an earlier expiry must not shorten the lease: after an extend to a later expiry, a stale out-of-order apply with an earlier expiry leaves the lease live past that earlier instant.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "extend_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_total": 2000, + + "expires_at_ms": 120000 + + }, + + { + + "op": "extend_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_total": 2000, + + "expires_at_ms": 30000 + + }, + + { "op": "advance_clock", "ms": 90000 }, + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 100, + + "expect": { "balance": 1900 } + + } + + ] + + }, + + { + + "name": "extend_pinned_to_lease_id_dropped_on_successor", + + "description": "An extend pinned to a lease the slot no longer holds is dropped entirely — crediting the successor would mint credits the server granted to the expired lease.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_2", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 300000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "extend_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_total": 5000, + + "expires_at_ms": 600000, + + "pin_lease_id": "lse_1" + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "granted_amount": 1000, "local_remaining_credits": 1000 } + + }, + + { + + "op": "extend_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_total": 2000, + + "expires_at_ms": 600000, + + "pin_lease_id": "lse_2" + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "granted_amount": 2000, "local_remaining_credits": 2000 } + + } + + ] + + } + + ] + +} + diff --git a/conformance/vectors/lease-manager.json b/conformance/vectors/lease-manager.json + new file mode 100644 + index 0000000..79ab1af + --- /dev/null + +++ b/conformance/vectors/lease-manager.json + @@ -0,0 +1,417 @@ + +{ + + "category": "lease_manager", + + "vectors": [ + + { + + "name": "acquire_installs_tranche_and_reuses_live_lease", + + "description": "First acquire requests lease_size from the server and installs the response at full grant; a second acquire while the lease is live makes no wire call.", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + } + + }, + + "operations": [ + + { + + "op": "acquire_if_needed", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "server": { + + "lease": { "lease_id": "lse_1", "granted_amount": 1000, "expires_at_ms": 300000 } + + }, + + "expect": { + + "lease_id": "lse_1", + + "wire_acquires": 1, + + "last_acquire_requested_amount": 1000, + + "released_lease_ids": [] + + } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "lease_id": "lse_1", "local_remaining_credits": 1000 } + + }, + + { + + "op": "acquire_if_needed", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "lease_id": "lse_1", "wire_acquires": 1 } + + } + + ] + + }, + + { + + "name": "acquire_replaces_expired_slot_without_release", + + "description": "An expired slot triggers a fresh acquire that supplants the stale entry in place; the redundant-lease release path must not fire (replace wrote, it did not keep a live lease).", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + }, + + "leases": [ + + { + + "lease_id": "lse_stale", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { "op": "advance_clock", "ms": 60001 }, + + { + + "op": "acquire_if_needed", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "server": { + + "lease": { "lease_id": "lse_fresh", "granted_amount": 1000, "expires_at_ms": 360000 } + + }, + + "expect": { "lease_id": "lse_fresh", "wire_acquires": 1, "released_lease_ids": [] } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "lease_id": "lse_fresh", "local_remaining_credits": 1000 } + + } + + ] + + }, + + { + + "name": "lost_acquire_race_different_id_releases_redundant_lease", + + "description": "A sibling installs a live lease while this acquire's wire call is in flight. The installed lease (with its debited balance) wins; the lease the server minted for the loser is redundant and gets released so it is not orphaned against the company balance.", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + } + + }, + + "operations": [ + + { + + "op": "acquire_if_needed", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "server": { + + "lease": { "lease_id": "lse_loser", "granted_amount": 1000, "expires_at_ms": 300000 } + + }, + + "install_during_wire": { + + "lease_id": "lse_winner", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 300000 + + }, + + "expect": { "lease_id": "lse_winner", "wire_acquires": 1, "released_lease_ids": ["lse_loser"] } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "lease_id": "lse_winner" } + + } + + ] + + }, + + { + + "name": "lost_acquire_race_same_id_releases_nothing", + + "description": "The server is idempotent for an active slot: a racing acquire is handed back the SAME lease the sibling installed. There is nothing to release — releasing would pull the shared lease out from under every sibling.", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + } + + }, + + "operations": [ + + { + + "op": "acquire_if_needed", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "server": { + + "lease": { "lease_id": "lse_shared", "granted_amount": 1000, "expires_at_ms": 300000 } + + }, + + "install_during_wire": { + + "lease_id": "lse_shared", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 300000 + + }, + + "expect": { "lease_id": "lse_shared", "wire_acquires": 1, "released_lease_ids": [] } + + } + + ] + + }, + + { + + "name": "extend_triggered_at_low_water_mark_requests_tranche", + + "description": "At or below the low-water-mark ratio a steady-state extend fires, requesting the configured tranche (lease_size) and reconciling the local row to the server total.", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + }, + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 300000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 800, + + "expect": { "balance": 200 } + + }, + + { + + "op": "maybe_extend", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "server": { "lease": { "granted_total": 2000, "expires_at_ms": 600000 } }, + + "expect": { + + "wire_extends": 1, + + "last_extend_additional_amount": 1000, + + "last_extend_lease_id": "lse_1" + + } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "granted_amount": 2000, "local_remaining_credits": 1200 } + + } + + ] + + }, + + { + + "name": "extend_triggered_by_required_credits_above_watermark", + + "description": "Above the watermark no steady-state extend fires; a required_credits hint larger than the local remaining triggers one anyway (a check just failed a reserve of that size).", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + }, + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 300000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 100, + + "expect": { "balance": 900 } + + }, + + { + + "op": "maybe_extend", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "wire_extends": 0 } + + }, + + { + + "op": "maybe_extend", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "required_credits": 1500, + + "server": { "lease": { "granted_total": 2000, "expires_at_ms": 600000 } }, + + "expect": { "wire_extends": 1, "last_extend_additional_amount": 1000 } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "granted_amount": 2000, "local_remaining_credits": 1900 } + + } + + ] + + }, + + { + + "name": "extend_sized_to_shortfall_when_larger_than_tranche", + + "description": "additional_amount = max(lease_size, required_credits - local_remaining): a single request larger than remaining + tranche must extend by the shortfall, or its post-extend retry would fail forever regardless of server balance.", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + }, + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 300000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 100, + + "expect": { "balance": 900 } + + }, + + { + + "op": "maybe_extend", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "required_credits": 5000, + + "server": { "lease": { "granted_total": 5100, "expires_at_ms": 600000 } }, + + "expect": { "wire_extends": 1, "last_extend_additional_amount": 4100 } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "granted_amount": 5100, "local_remaining_credits": 5000 } + + } + + ] + + }, + + { + + "name": "never_extend_an_expired_lease", + + "description": "An expired lease is released as far as the server is concerned — the only correct move is a fresh acquire, never an extend, no matter how depleted the balance.", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + }, + + "leases": [ + + { + + "lease_id": "lse_old", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 900, + + "expect": { "balance": 100 } + + }, + + { "op": "advance_clock", "ms": 60001 }, + + { + + "op": "maybe_extend", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "required_credits": 1500, + + "expect": { "wire_extends": 0 } + + } + + ] + + }, + + { + + "name": "wire_failures_resolve_to_no_lease_without_state_changes", + + "description": "A failed acquire yields no lease and installs nothing; a failed extend leaves the local row untouched. Neither throws (both are routed through fail-open/fail-closed by callers).", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + } + + }, + + "operations": [ + + { + + "op": "acquire_if_needed", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "server": { "error": "wire down" }, + + "expect": { "lease_id": null, "wire_acquires": 1 } + + }, + + { "op": "get_lease", "company_id": "co_1", "credit_type_id": "ct_1", "expect": { "exists": false } }, + + { + + "op": "replace_lease", + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 300000, + + "expect": { "written": true } + + }, + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 800, + + "expect": { "balance": 200 } + + }, + + { + + "op": "maybe_extend", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "server": { "error": "wire down" }, + + "expect": { "wire_extends": 1 } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "granted_amount": 1000, "local_remaining_credits": 200 } + + } + + ] + + }, + + { + + "name": "release_all_releases_live_and_skips_expired", + + "description": "On close, a per-process store releases its live leases over the wire (returning remainders immediately) and drops them locally; expired leases are skipped — the server already swept them. Only valid for an exclusively-owned (in-memory) store; a shared backend must never enumerate-and-release.", + + "backends": ["in_memory"], + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + }, + + "leases": [ + + { + + "lease_id": "lse_live", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 300000 + + }, + + { + + "lease_id": "lse_expired", + + "company_id": "co_2", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 30000 + + } + + ] + + }, + + "operations": [ + + { "op": "advance_clock", "ms": 30001 }, + + { + + "op": "release_all_local_leases", + + "expect": { "released_lease_ids": ["lse_live"] } + + }, + + { "op": "get_lease", "company_id": "co_1", "credit_type_id": "ct_1", "expect": { "exists": false } } + + ] + + } + + ] + +} + diff --git a/conformance/vectors/reservation-lifecycle.json b/conformance/vectors/reservation-lifecycle.json + new file mode 100644 + index 0000000..eea6b50 + --- /dev/null + +++ b/conformance/vectors/reservation-lifecycle.json + @@ -0,0 +1,353 @@ + +{ + + "category": "reservation_lifecycle", + + "vectors": [ + + { + + "name": "consume_exact_usage_no_refund", + + "description": "Consuming exactly the reserved amount removes the reservation and refunds nothing.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 100, + + "expect": { "balance": 900 } + + }, + + { + + "op": "add_reservation", + + "id": "res_1", + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "event_subtype": "inference_tokens", + + "quantity_reserved": 10, + + "credits_reserved": 100, + + "consumption_rate": 10, + + "expires_at_ms": 60000 + + }, + + { "op": "consume_reservation", "id": "res_1", "credits": 100, "expect": { "consumed": 100 } }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 900 } + + }, + + { "op": "get_reservation", "id": "res_1", "expect": { "exists": false } } + + ] + + }, + + { + + "name": "consume_under_reserved_refunds_unspent", + + "description": "Consuming less than reserved refunds the unspent slice to the lease in the same step.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 100, + + "expect": { "balance": 900 } + + }, + + { + + "op": "add_reservation", + + "id": "res_1", + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "event_subtype": "inference_tokens", + + "quantity_reserved": 10, + + "credits_reserved": 100, + + "consumption_rate": 10, + + "expires_at_ms": 60000 + + }, + + { "op": "consume_reservation", "id": "res_1", "credits": 30, "expect": { "consumed": 30 } }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 970 } + + } + + ] + + }, + + { + + "name": "consume_over_reserved_clamps_to_hold", + + "description": "Local consumption is clamped to credits_reserved: over-use consumes the full hold, refunds nothing, and never debits the lease beyond the reservation. (The billed Track quantity is NOT clamped — see track-settle vectors.)", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 100, + + "expect": { "balance": 900 } + + }, + + { + + "op": "add_reservation", + + "id": "res_1", + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "event_subtype": "inference_tokens", + + "quantity_reserved": 10, + + "credits_reserved": 100, + + "consumption_rate": 10, + + "expires_at_ms": 60000 + + }, + + { "op": "consume_reservation", "id": "res_1", "credits": 999, "expect": { "consumed": 100 } }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 900 } + + } + + ] + + }, + + { + + "name": "consume_zero_cancels_with_full_refund", + + "description": "Consuming 0 credits acts as a cancel: the full hold is refunded. Negative consumption clamps to 0 the same way.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 100, + + "expect": { "balance": 900 } + + }, + + { + + "op": "add_reservation", + + "id": "res_1", + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "event_subtype": "inference_tokens", + + "quantity_reserved": 10, + + "credits_reserved": 100, + + "consumption_rate": 10, + + "expires_at_ms": 60000 + + }, + + { "op": "consume_reservation", "id": "res_1", "credits": 0, "expect": { "consumed": 0 } }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 1000 } + + } + + ] + + }, + + { + + "name": "consume_is_exactly_once", + + "description": "A missing reservation and a second consume of the same id both return null and refund nothing — the claim is the exactly-once arbiter.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { "op": "consume_reservation", "id": "res_missing", "credits": 10, "expect": { "consumed": null } }, + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 100, + + "expect": { "balance": 900 } + + }, + + { + + "op": "add_reservation", + + "id": "res_1", + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "event_subtype": "inference_tokens", + + "quantity_reserved": 10, + + "credits_reserved": 100, + + "consumption_rate": 10, + + "expires_at_ms": 60000 + + }, + + { "op": "consume_reservation", "id": "res_1", "credits": 30, "expect": { "consumed": 30 } }, + + { "op": "consume_reservation", "id": "res_1", "credits": 30, "expect": { "consumed": null } }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 970 } + + } + + ] + + }, + + { + + "name": "reserved_credits_sums_open_holds_per_slot", + + "description": "reserved_credits sums credits_reserved across open reservations for the exact (company, credit) slot only, and a hold stops counting the moment it is consumed.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 350, + + "expect": { "balance": 650 } + + }, + + { + + "op": "add_reservation", + + "id": "res_1", + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "event_subtype": "inference_tokens", + + "quantity_reserved": 10, + + "credits_reserved": 100, + + "consumption_rate": 10, + + "expires_at_ms": 60000 + + }, + + { + + "op": "add_reservation", + + "id": "res_2", + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "event_subtype": "inference_tokens", + + "quantity_reserved": 25, + + "credits_reserved": 250, + + "consumption_rate": 10, + + "expires_at_ms": 60000 + + }, + + { + + "op": "add_reservation", + + "id": "res_other_credit", + + "lease_id": "lse_9", + + "company_id": "co_1", + + "credit_type_id": "ct_2", + + "event_subtype": "inference_tokens", + + "quantity_reserved": 99, + + "credits_reserved": 999, + + "consumption_rate": 10, + + "expires_at_ms": 60000 + + }, + + { + + "op": "add_reservation", + + "id": "res_other_company", + + "lease_id": "lse_8", + + "company_id": "co_2", + + "credit_type_id": "ct_1", + + "event_subtype": "inference_tokens", + + "quantity_reserved": 99, + + "credits_reserved": 999, + + "consumption_rate": 10, + + "expires_at_ms": 60000 + + }, + + { + + "op": "reserved_credits", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "total": 350 } + + }, + + { + + "op": "reserved_credits", + + "company_id": "co_1", + + "credit_type_id": "ct_2", + + "expect": { "total": 999 } + + }, + + { "op": "reserved_credits", "company_id": "co_9", "credit_type_id": "ct_1", "expect": { "total": 0 } }, + + { "op": "consume_reservation", "id": "res_1", "credits": 40, "expect": { "consumed": 40 } }, + + { "op": "reserved_credits", "company_id": "co_1", "credit_type_id": "ct_1", "expect": { "total": 250 } } + + ] + + }, + + { + + "name": "fractional_credit_amounts_are_exact", + + "description": "Fractional consumption rates produce fractional holds; reserve, consume, and refund arithmetic must not truncate.", + + "given": { + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 10, + + "expires_at_ms": 60000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "try_reserve", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "credits": 2.5, + + "expect": { "balance": 7.5 } + + }, + + { + + "op": "add_reservation", + + "id": "res_1", + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "event_subtype": "inference_tokens", + + "quantity_reserved": 25, + + "credits_reserved": 2.5, + + "consumption_rate": 0.1, + + "expires_at_ms": 60000 + + }, + + { "op": "consume_reservation", "id": "res_1", "credits": 1.5, "expect": { "consumed": 1.5 } }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 8.5 } + + } + + ] + + } + + ] + +} + diff --git a/conformance/vectors/track-settle.json b/conformance/vectors/track-settle.json + new file mode 100644 + index 0000000..1da71a0 + --- /dev/null + +++ b/conformance/vectors/track-settle.json + @@ -0,0 +1,194 @@ + +{ + + "category": "track_settle", + + "vectors": [ + + { + + "name": "track_underuse_settles_and_refunds_unspent", + + "description": "Settling with less than the reserved usage consumes actual x rate, refunds the unspent slice to the lease, and emits a Track billing the ACTUAL quantity keyed to the lease.", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + }, + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 300000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "check", + + "flag_key": "inference", + + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + + "usage": 10, + + "event_subtype": "inference_tokens", + + "save_reservation_as": "r1", + + "engine": [ + + { + + "value": true, + + "reason": "probe", + + "entitlement": { + + "value_type": "credit", + + "credit_id": "ct_1", + + "consumption_rate": 10, + + "event_subtype": "inference_tokens" + + } + + }, + + { "value": true, "reason": "ok" } + + ], + + "expect": { "allowed": true, "has_reservation": true } + + }, + + { + + "op": "track", + + "handle": "r1", + + "actual_quantity": 4, + + "expect": { + + "settled_locally": true, + + "track": { "event": "inference_tokens", "quantity": 4, "lease_id": "lse_1" } + + } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 960 } + + }, + + { "op": "reserved_credits", "company_id": "co_1", "credit_type_id": "ct_1", "expect": { "total": 0 } } + + ] + + }, + + { + + "name": "track_overuse_bills_actual_but_clamps_local_debit", + + "description": "Actual usage above the reservation: the LOCAL settle clamps consumption to the reserved hold (the lease is never debited past the reservation), but the Track event bills the unclamped actual quantity — the server is the source of truth for real consumption.", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + }, + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 300000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "check", + + "flag_key": "inference", + + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + + "usage": 10, + + "event_subtype": "inference_tokens", + + "save_reservation_as": "r1", + + "engine": [ + + { + + "value": true, + + "reason": "probe", + + "entitlement": { + + "value_type": "credit", + + "credit_id": "ct_1", + + "consumption_rate": 10, + + "event_subtype": "inference_tokens" + + } + + }, + + { "value": true, "reason": "ok" } + + ], + + "expect": { "allowed": true, "has_reservation": true } + + }, + + { + + "op": "track", + + "handle": "r1", + + "actual_quantity": 25, + + "expect": { + + "settled_locally": true, + + "track": { "event": "inference_tokens", "quantity": 25, "lease_id": "lse_1" } + + } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 900 } + + } + + ] + + }, + + { + + "name": "track_after_sweep_is_a_recovery_emit", + + "description": "Work outliving the reservation TTL: the sweeper already refunded the full hold, so the late settle does not touch the lease (the local balance reads high until rollover) — but the Track is still emitted so the server bills the actual usage. Server-side idempotency (a deterministic key derived from the reservation id) is what keeps a racing normal emit from double-billing.", + + "given": { + + "config": { + + "lease_duration_ms": 300000, + + "reservation_ttl_ms": 60000, + + "lease_size": 1000, + + "low_water_mark": 0.25 + + }, + + "leases": [ + + { + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "granted_amount": 1000, + + "expires_at_ms": 300000 + + } + + ] + + }, + + "operations": [ + + { + + "op": "check", + + "flag_key": "inference", + + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + + "usage": 10, + + "event_subtype": "inference_tokens", + + "save_reservation_as": "r1", + + "engine": [ + + { + + "value": true, + + "reason": "probe", + + "entitlement": { + + "value_type": "credit", + + "credit_id": "ct_1", + + "consumption_rate": 10, + + "event_subtype": "inference_tokens" + + } + + }, + + { "value": true, "reason": "ok" } + + ], + + "expect": { "allowed": true, "has_reservation": true } + + }, + + { "op": "advance_clock", "ms": 60001 }, + + { "op": "sweep_expired", "expect": { "swept": 1 } }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 1000 } + + }, + + { + + "op": "track", + + "handle": "r1", + + "actual_quantity": 4, + + "expect": { + + "settled_locally": false, + + "track": { "event": "inference_tokens", "quantity": 4, "lease_id": "lse_1" } + + } + + }, + + { + + "op": "get_lease", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "expect": { "exists": true, "local_remaining_credits": 1000 } + + } + + ] + + } + + ] + +} + diff --git a/poetry.lock b/poetry.lock + index 416786d..59e6adf 100644 + --- a/poetry.lock + +++ b/poetry.lock + @@ -1,4 +1,4 @@ + -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. + +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + + [[package]] + name = "aiohappyeyeballs" + @@ -6,12 +6,10 @@ version = "2.7.1" + description = "Happy Eyeballs for asyncio" + optional = false + python-versions = ">=3.10" + -groups = ["main", "dev"] + files = [ + {file = "aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472"}, + {file = "aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d"}, + ] + -markers = {main = "extra == \"aiohttp\""} + + [[package]] + name = "aiohttp" + @@ -19,7 +17,6 @@ version = "3.14.3" + description = "Async http client/server framework (asyncio)" + optional = false + python-versions = ">=3.10" + -groups = ["main", "dev"] + files = [ + {file = "aiohttp-3.14.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b"}, + {file = "aiohttp-3.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a"}, + @@ -141,7 +138,6 @@ files = [ + {file = "aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7"}, + {file = "aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc"}, + ] + -markers = {main = "extra == \"aiohttp\""} + + [package.dependencies] + aiohappyeyeballs = ">=2.5.0" + @@ -155,7 +151,7 @@ typing_extensions = {version = ">=4.4", markers = "python_version < \"3.13\""} + yarl = ">=1.17.0,<2.0" + + [package.extras] + -speedups = ["Brotli (>=1.2) ; platform_python_implementation == \"CPython\" and sys_platform != \"android\" and sys_platform != \"ios\"", "aiodns (>=3.3.0) ; sys_platform != \"android\" and sys_platform != \"ios\"", "backports.zstd ; platform_python_implementation == \"CPython\" and python_version < \"3.14\" and sys_platform != \"android\" and sys_platform != \"ios\"", "brotlicffi (>=1.2) ; platform_python_implementation != \"CPython\""] + +speedups = ["Brotli (>=1.2)", "aiodns (>=3.3.0)", "backports.zstd", "brotlicffi (>=1.2)"] + + [[package]] + name = "aiosignal" + @@ -163,12 +159,10 @@ version = "1.4.0" + description = "aiosignal: a list of registered asynchronous callbacks" + optional = false + python-versions = ">=3.9" + -groups = ["main", "dev"] + files = [ + {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, + {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, + ] + -markers = {main = "extra == \"aiohttp\""} + + [package.dependencies] + frozenlist = ">=1.1.0" + @@ -180,7 +174,6 @@ version = "0.8.0" + description = "Reusable constraint types to use with typing.Annotated" + optional = false + python-versions = ">=3.10" + -groups = ["main"] + files = [ + {file = "annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0"}, + {file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"}, + @@ -192,7 +185,6 @@ version = "4.15.0" + description = "High-level concurrency and networking framework on top of asyncio or Trio" + optional = false + python-versions = ">=3.10" + -groups = ["main"] + files = [ + {file = "anyio-4.15.0-py3-none-any.whl", hash = "sha256:7ecd9937369ffce8bba0b5ccb9b3a9507b101b0ed50256aecfbab27e6c2acb99"}, + {file = "anyio-4.15.0.tar.gz", hash = "sha256:b5c620ed540725e2579c31b17bb995b3bf02c9281c9cace04c7d186380bab85e"}, + @@ -212,12 +204,10 @@ version = "5.0.1" + description = "Timeout context manager for asyncio programs" + optional = false + python-versions = ">=3.8" + -groups = ["main", "dev"] + files = [ + {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, + {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, + ] + -markers = {main = "extra == \"aiohttp\" and python_version == \"3.10\"", dev = "python_full_version < \"3.11.3\""} + + [[package]] + name = "attrs" + @@ -225,12 +215,10 @@ version = "26.1.0" + description = "Classes Without Boilerplate" + optional = false + python-versions = ">=3.9" + -groups = ["main", "dev"] + files = [ + {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, + {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, + ] + -markers = {main = "extra == \"aiohttp\""} + + [[package]] + name = "certifi" + @@ -238,7 +226,6 @@ version = "2026.7.22" + description = "Python package for providing Mozilla's CA Bundle." + optional = false + python-versions = ">=3.7" + -groups = ["main"] + files = [ + {file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"}, + {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, + @@ -250,8 +237,6 @@ version = "0.4.6" + description = "Cross-platform colored terminal text." + optional = false + python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" + -groups = ["dev"] + -markers = "sys_platform == \"win32\"" + files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, + @@ -263,8 +248,6 @@ version = "1.3.1" + description = "Backport of PEP 654 (exception groups)" + optional = false + python-versions = ">=3.7" + -groups = ["main", "dev"] + -markers = "python_version == \"3.10\"" + files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, + @@ -282,7 +265,6 @@ version = "2.1.2" + description = "execnet: rapid multi-Python deployment" + optional = false + python-versions = ">=3.8" + -groups = ["dev"] + files = [ + {file = "execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec"}, + {file = "execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd"}, + @@ -291,13 +273,38 @@ files = [ + [package.extras] + testing = ["hatch", "pre-commit", "pytest", "tox"] + + +[[package]] + +name = "fakeredis" + +version = "2.38.0" + +description = "Python implementation of redis API, can be used for testing purposes." + +optional = false + +python-versions = ">=3.8" + +files = [ + + {file = "fakeredis-2.38.0-py3-none-any.whl", hash = "sha256:d9fb0518c4eaa35f1f2c94df6b4a4c97ff3ca9f43d6cc8112317a9037d244301"}, + + {file = "fakeredis-2.38.0.tar.gz", hash = "sha256:d2abfd24652f86501044499bf08c9d639db050f695eefc06b8b8b6f0bb24dbd6"}, + +] + + + +[package.dependencies] + +lupa = {version = ">=2.1", optional = true, markers = "extra == \"lua\""} + +redis = ">=4.3" + +sortedcontainers = ">=2" + +typing-extensions = {version = ">=4.7", markers = "python_version < \"3.11\""} + + + +[package.extras] + +bf = ["pyprobables (>=0.6)"] + +cf = ["pyprobables (>=0.6)"] + +json = ["jsonpath-ng (>=1.6)"] + +lua = ["lupa (>=2.1)"] + +probabilistic = ["pyprobables (>=0.6)"] + +valkey = ["valkey (>=6)"] + +vectorset = ["jsonpath-ng (>=1.6)", "numpy (>=2.4.0)"] + + + [[package]] + name = "frozenlist" + version = "1.8.0" + description = "A list-like structure which implements collections.abc.MutableSequence" + optional = false + python-versions = ">=3.9" + -groups = ["main", "dev"] + files = [ + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"}, + @@ -430,7 +437,6 @@ files = [ + {file = "frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d"}, + {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"}, + ] + -markers = {main = "extra == \"aiohttp\""} + + [[package]] + name = "h11" + @@ -438,7 +444,6 @@ version = "0.16.0" + description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" + optional = false + python-versions = ">=3.8" + -groups = ["main"] + files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, + @@ -450,7 +455,6 @@ version = "1.0.9" + description = "A minimal low-level HTTP client." + optional = false + python-versions = ">=3.8" + -groups = ["main"] + files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, + @@ -472,7 +476,6 @@ version = "0.28.1" + description = "The next generation HTTP client." + optional = false + python-versions = ">=3.8" + -groups = ["main"] + files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, + @@ -485,7 +488,7 @@ httpcore = "==1.*" + idna = "*" + + [package.extras] + -brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] + +brotli = ["brotli", "brotlicffi"] + cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] + http2 = ["h2 (>=3,<5)"] + socks = ["socksio (==1.*)"] + @@ -497,8 +500,6 @@ version = "0.1.12" + description = "Aiohttp transport for HTTPX" + optional = true + python-versions = ">=3.8" + -groups = ["main"] + -markers = "extra == \"aiohttp\"" + files = [ + {file = "httpx_aiohttp-0.1.12-py3-none-any.whl", hash = "sha256:5b0eac39a7f360fa7867a60bcb46bb1024eada9c01cbfecdb54dc1edb3fb7141"}, + {file = "httpx_aiohttp-0.1.12.tar.gz", hash = "sha256:81feec51fd82c0ecfa0e9aaf1b1a6c2591260d5e2bcbeb7eb0277a78e610df2c"}, + @@ -514,7 +515,6 @@ version = "3.19" + description = "Internationalized Domain Names in Applications (IDNA)" + optional = false + python-versions = ">=3.9" + -groups = ["main", "dev"] + files = [ + {file = "idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4"}, + {file = "idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15"}, + @@ -529,19 +529,93 @@ version = "2.3.0" + description = "brain-dead simple config-ini parsing" + optional = false + python-versions = ">=3.10" + -groups = ["dev"] + files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, + ] + + +[[package]] + +name = "lupa" + +version = "2.8" + +description = "Python wrapper around Lua and LuaJIT" + +optional = false + +python-versions = ">=3.8" + +files = [ + + {file = "lupa-2.8-cp310-abi3-win32.whl", hash = "sha256:c2a5fd15dc62374e1661a55f01744c9ec1c56f291ba4a0749d3af2174556e78f"}, + + {file = "lupa-2.8-cp310-abi3-win_arm64.whl", hash = "sha256:9e304fb1c50cf23fd8882afbe1aa87525ef8a72667bcab3b37b2bbb2bc542269"}, + + {file = "lupa-2.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:97bd01e90b8031e56a5fd5bb70605aea09f1dba675c1140308a52780f93d06f1"}, + + {file = "lupa-2.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b5ebe1a13c45767919c86750b84fe2da9f6288b6f3cea4ce7660bb2abc9d921"}, + + {file = "lupa-2.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:097e7d0f1719a88020b67c82e05d53d7973c166952393afcecfd8434c7e19a15"}, + + {file = "lupa-2.8-cp310-cp310-win_amd64.whl", hash = "sha256:7bb223ee8f72d0dc076b0d65296ee72f1c69450f9d2fed5315f7707d98c4a03d"}, + + {file = "lupa-2.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b12e43c1fb787189dfc28cd604aef0baa2cb95e27da19498d520361d0ace070a"}, + + {file = "lupa-2.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f603391dffb256e36a79fd2044084d5f4b8a0a4c0e5ad291cd3ab3aaf1fd0a"}, + + {file = "lupa-2.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f6f41c91366e7d0d474f87d81c1274af861f40812bf729c9f97ab4c8f3c7ac8"}, + + {file = "lupa-2.8-cp311-cp311-win_amd64.whl", hash = "sha256:f5a6af145b0ea818f01d27bfe2583a4b538570bef61d22c8773e0eccf011234c"}, + + {file = "lupa-2.8-cp312-abi3-macosx_10_13_x86_64.whl", hash = "sha256:f4342f4de76ae7ce2ab0672d36003bdb7e1a33252f293b569298ddd792e70e33"}, + + {file = "lupa-2.8-cp312-abi3-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:4203fa1659315e939a5304e75001b8cc14234fb3cbb3ed86c049b0cc5d90fcee"}, + + {file = "lupa-2.8-cp312-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:81f2d843ce668b653146c007467570210ae44be51dac6926666c51d49536f307"}, + + {file = "lupa-2.8-cp312-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3d0cde2c77588d1c60875a4f34f059513476c6e1775351897195b51e0f3df08"}, + + {file = "lupa-2.8-cp312-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e0d11b8f3a8dac6413f704fef7161d048bb10c58bdac6cbffa5e60efa56e9a3"}, + + {file = "lupa-2.8-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:54cff414f21f8cd8c6be4aae52541f3b9cd39602b59e3a3db9b5c9f9f674ff18"}, + + {file = "lupa-2.8-cp312-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:24b4d8af5558e549b70daf1547f5c1c1d664ecea9fc790f83efe5d75e9a93797"}, + + {file = "lupa-2.8-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:ce86dff1ee7f7cf45f5622065ae991949dd7bb1703581cbc58a630137bb7ccf9"}, + + {file = "lupa-2.8-cp312-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f4d01b2a08c70bbb883a9e082b6b36b89121ed5910b710f1ba11c73295ff4fba"}, + + {file = "lupa-2.8-cp312-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:7f210d5a8353e510ea1199c42cf3cbdd630553bf2bc8fb4c00fea06fdec7c798"}, + + {file = "lupa-2.8-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4f81a02806e7c7ad26d8c6fa222c8bef1b0c1b124347c879be880b41339d41e4"}, + + {file = "lupa-2.8-cp312-abi3-win32.whl", hash = "sha256:360056453a7a4eaa4ac5a204c31a5a014b1eb2ee5490603234d2ba831684f1f2"}, + + {file = "lupa-2.8-cp312-abi3-win_arm64.whl", hash = "sha256:1628371c6592a6d5650497a9e31fb2bb3a7e9883c1f301d1111265e484045af9"}, + + {file = "lupa-2.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:450650f91c48c2415b0d59ab3abfcfda3b6efb5b858205f4d4bda8ad141fa529"}, + + {file = "lupa-2.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27044f3363047f946b3d3aab9157cbd172b3538ada9ec1baef43432bf7d03a78"}, + + {file = "lupa-2.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cf4f064a0e5531afce2d7d750120c10c10f9529139af6ca6150d13151034398"}, + + {file = "lupa-2.8-cp312-cp312-win_amd64.whl", hash = "sha256:281bedc5deb92d31e649a3552edd662449365a635904fa4d5cb4509c7245e34e"}, + + {file = "lupa-2.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45fc9da0145ecb0083ef5ff9975116cc784bd0258bdc2bd131ba15483ce18398"}, + + {file = "lupa-2.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58e18afed57955b41130e269c78f53d4123ab86e236b53816f4cbffa25cb5d30"}, + + {file = "lupa-2.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc47f536ac13a79cef47d29a2b205576a22841f042a2bcec1676b95806e7706a"}, + + {file = "lupa-2.8-cp313-cp313-win_amd64.whl", hash = "sha256:ce9404c661dbac65cc9bed351ad45e797af93d30d70be309a3fa8209ac86d93b"}, + + {file = "lupa-2.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:348c3f8ecabb6324dcbc05c2740d762ef8fcec7b06c79e45262ab97a217684e3"}, + + {file = "lupa-2.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:951496471056061598a7d1729a6cdf48d662fec777a9f2d8aa5a1e62fd30e5a5"}, + + {file = "lupa-2.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a591b9947ca347b41a63370e121d6e2b1458fe6dde9ae065029ec10a37f25ff4"}, + + {file = "lupa-2.8-cp314-cp314-win_amd64.whl", hash = "sha256:3903c9cf628dae2f56405503247b77a61a3a61bd2dda470e336950c74776d55d"}, + + {file = "lupa-2.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f711a8ab0486b9ac6fdda94a22ddcfbc9f0d4a27e3a8cf1bf79c6e48b33017c1"}, + + {file = "lupa-2.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc51250e76367a3e27fcd01dc769b9bfcbbc34f48df48dde53d6af6e75b7eaa5"}, + + {file = "lupa-2.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8a22088a552828958603323f0a5c4b3e11e03b75d0bf4c965ef879de9b60a8d"}, + + {file = "lupa-2.8-cp314-cp314t-win32.whl", hash = "sha256:4f7c553c1d8cfffbe85d81daef730d12cae4b6002d457542914da0ac8a1145b3"}, + + {file = "lupa-2.8-cp314-cp314t-win_amd64.whl", hash = "sha256:d8766aff03a78c80ad2d188a8bdb216de5ec838359cd87e05bbdfa56394a6105"}, + + {file = "lupa-2.8-cp314-cp314t-win_arm64.whl", hash = "sha256:91d622777febda3ab1bed1d45295f2f32a4680c7b3d7caf8c669998ed5c44118"}, + + {file = "lupa-2.8-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81b283bfb13cc43fa4910fc98ec110ab861bcb39680f48b266f99d6e3be1049e"}, + + {file = "lupa-2.8-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf45d15d424cee52fd67341e96e2b1dde0658ae90eb156ac56aa0d8330bc38"}, + + {file = "lupa-2.8-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33e7e5aebca64b154b0a1679caf79e19254ff37bba51e87abab6848f97cb2de1"}, + + {file = "lupa-2.8-cp38-cp38-win32.whl", hash = "sha256:e8d4f4dd4acf4a0e42adc6b1ad220e1c86fe3028402c2f78bd0728a6d241bbe9"}, + + {file = "lupa-2.8-cp38-cp38-win_amd64.whl", hash = "sha256:1ac2b1ec7504e6148cba1bc35ac36c74d18a0ca6d367ffe7e78a3773c2694c0e"}, + + {file = "lupa-2.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b036738282a5acd2e71fdddb317c9df8b87c1673aa57f403d05fcc2be8abc4ba"}, + + {file = "lupa-2.8-cp39-abi3-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:ac6b6e8d0e617e26a98cbb44880bcd75de5d32b3ad7b3b3793583909292b47ed"}, + + {file = "lupa-2.8-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ba3a7dd839f90c3d2e53bebe3c192b1f3f9fd720a6781256405123211fd0dce6"}, + + {file = "lupa-2.8-cp39-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d7edb13a7a5250b5c6c22d1495d9e842b5c9fc5081c8fe6b5efe2112fe3e41f9"}, + + {file = "lupa-2.8-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:891f72e0bffbed1e4175f975aeb2a083956586a100066525e1be485f617f7b25"}, + + {file = "lupa-2.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a295f87b5b7ebbfd5191932e8cb0e51df3c7769101ac6b6c7d7c9fb27bfd1307"}, + + {file = "lupa-2.8-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4fe5d7a810b64ea8511eb885fc8cdde042ee5ff7b7d08ae78f32449756acb177"}, + + {file = "lupa-2.8-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:bfc470012ef66ad064c7bd77416af03a3452ef630b04b9012595ea13f2e54518"}, + + {file = "lupa-2.8-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:250e035fdaffe8c87093e3ebc206ac29a26131b1568ea711d780c26001ce96e7"}, + + {file = "lupa-2.8-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:b9bddb09acfffb4f828f790f444b11dc0cca591afea1a244d9329eea2d20c003"}, + + {file = "lupa-2.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2e64acbbd47e9b82a64405a39e0d2b36a5a7dad8ab41c0f3437f572f7d282ba3"}, + + {file = "lupa-2.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f6ddca4774d5ca451768a95e378a3aa041076e29f4613b8562f8e98efb6690fd"}, + + {file = "lupa-2.8-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ffcfd8e19f943ad459136b3f60f085ae4948f024192a93ca4b4ac3023ec88d8"}, + + {file = "lupa-2.8-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f3f3955f65f9fde2dc6eda3041ccd394cf54d4bf083f0cdf6feb3d58e5f38d3"}, + + {file = "lupa-2.8-cp39-cp39-win32.whl", hash = "sha256:9e76e45057cfcaa20ee3422c2289a91f9d51783d020da3570ee226de8f6e71cd"}, + + {file = "lupa-2.8-cp39-cp39-win_amd64.whl", hash = "sha256:6fbcc9911f05c67affbd225fc024268e61e98a18ad1b1c2aed6c8796e4056554"}, + + {file = "lupa-2.8-cp39-cp39-win_arm64.whl", hash = "sha256:6c817d5421094507662e5f8feb8cd1e154c10879921c06079b6063be9d8f33c5"}, + + {file = "lupa-2.8-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e4e5103bbddcdd2458fb2ccae6c8ba11c9997c711d7e379e0d45551d109c76"}, + + {file = "lupa-2.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7667001804657496dee9feced2daae5000b4604a3218dd8e6b7b754982ba88b8"}, + + {file = "lupa-2.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:86f6f668966965b15247dc32d064cfe7be67b71e584ccfacbe2f637575296878"}, + + {file = "lupa-2.8.tar.gz", hash = "sha256:d8022641b9ec8ecf2c5ecbe9f47e5a70e0b87c4b5ae921b92cb02a638e0acd08"}, + +] + + + [[package]] + name = "multidict" + version = "6.7.1" + description = "multidict implementation" + optional = false + python-versions = ">=3.9" + -groups = ["main", "dev"] + files = [ + {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5"}, + {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8"}, + @@ -690,7 +764,6 @@ files = [ + {file = "multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56"}, + {file = "multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d"}, + ] + -markers = {main = "extra == \"aiohttp\""} + + [package.dependencies] + typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} + @@ -701,7 +774,6 @@ version = "1.13.0" + description = "Optional static typing for Python" + optional = false + python-versions = ">=3.8" + -groups = ["dev"] + files = [ + {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, + {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, + @@ -755,7 +827,6 @@ version = "1.1.0" + description = "Type system extensions for programs checked with the mypy type checker." + optional = false + python-versions = ">=3.8" + -groups = ["dev"] + files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, + @@ -767,7 +838,6 @@ version = "26.3" + description = "Core utilities for Python packages" + optional = false + python-versions = ">=3.9" + -groups = ["dev"] + files = [ + {file = "packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c"}, + {file = "packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79"}, + @@ -779,7 +849,6 @@ version = "1.6.0" + description = "plugin and hook calling mechanisms for python" + optional = false + python-versions = ">=3.9" + -groups = ["dev"] + files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, + @@ -795,7 +864,6 @@ version = "0.5.2" + description = "Accelerated property cache" + optional = false + python-versions = ">=3.10" + -groups = ["main", "dev"] + files = [ + {file = "propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b"}, + {file = "propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c"}, + @@ -919,7 +987,6 @@ files = [ + {file = "propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe"}, + {file = "propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427"}, + ] + -markers = {main = "extra == \"aiohttp\""} + + [[package]] + name = "pydantic" + @@ -927,7 +994,6 @@ version = "2.13.5" + description = "Data validation using Python type hints" + optional = false + python-versions = ">=3.9" + -groups = ["main"] + files = [ + {file = "pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73"}, + {file = "pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08"}, + @@ -941,7 +1007,7 @@ typing-inspection = ">=0.4.2" + + [package.extras] + email = ["email-validator (>=2.0.0)"] + -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +timezone = ["tzdata"] + + [[package]] + name = "pydantic-core" + @@ -949,7 +1015,6 @@ version = "2.46.5" + description = "Core functionality for Pydantic validation and serialization" + optional = false + python-versions = ">=3.9" + -groups = ["main"] + files = [ + {file = "pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6"}, + {file = "pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615"}, + @@ -1082,7 +1147,6 @@ version = "2.13.0" + description = "JSON Web Token implementation in Python" + optional = false + python-versions = ">=3.9" + -groups = ["dev"] + files = [ + {file = "pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728"}, + {file = "pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423"}, + @@ -1100,7 +1164,6 @@ version = "7.4.4" + description = "pytest: simple powerful testing with Python" + optional = false + python-versions = ">=3.7" + -groups = ["dev"] + files = [ + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, + @@ -1123,7 +1186,6 @@ version = "0.23.8" + description = "Pytest support for asyncio" + optional = false + python-versions = ">=3.8" + -groups = ["dev"] + files = [ + {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, + {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, + @@ -1142,7 +1204,6 @@ version = "3.8.0" + description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" + optional = false + python-versions = ">=3.9" + -groups = ["dev"] + files = [ + {file = "pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88"}, + {file = "pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1"}, + @@ -1163,7 +1224,6 @@ version = "2.9.0.post0" + description = "Extensions to the standard Python datetime module" + optional = false + python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + -groups = ["dev"] + files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, + @@ -1178,7 +1238,6 @@ version = "5.3.1" + description = "Python client for Redis database and key-value store" + optional = false + python-versions = ">=3.8" + -groups = ["dev"] + files = [ + {file = "redis-5.3.1-py3-none-any.whl", hash = "sha256:dc1909bd24669cc31b5f67a039700b16ec30571096c5f1f0d9d2324bff31af97"}, + {file = "redis-5.3.1.tar.gz", hash = "sha256:ca49577a531ea64039b5a36db3d6cd1a0c7a60c34124d46924a45b956e8cf14c"}, + @@ -1198,7 +1257,6 @@ version = "0.11.5" + description = "An extremely fast Python linter and code formatter, written in Rust." + optional = false + python-versions = ">=3.7" + -groups = ["dev"] + files = [ + {file = "ruff-0.11.5-py3-none-linux_armv6l.whl", hash = "sha256:2561294e108eb648e50f210671cc56aee590fb6167b594144401532138c66c7b"}, + {file = "ruff-0.11.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac12884b9e005c12d0bd121f56ccf8033e1614f736f766c118ad60780882a077"}, + @@ -1226,20 +1284,28 @@ version = "1.17.0" + description = "Python 2 and 3 compatibility utilities" + optional = false + python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + -groups = ["dev"] + files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, + ] + + +[[package]] + +name = "sortedcontainers" + +version = "2.4.0" + +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" + +optional = false + +python-versions = "*" + +files = [ + + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, + +] + + + [[package]] + name = "tomli" + version = "2.4.1" + description = "A lil' TOML parser" + optional = false + python-versions = ">=3.8" + -groups = ["dev"] + -markers = "python_version == \"3.10\"" + files = [ + {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, + @@ -1296,7 +1362,6 @@ version = "2.9.0.20260807" + description = "Typing stubs for python-dateutil" + optional = false + python-versions = ">=3.10" + -groups = ["dev"] + files = [ + {file = "types_python_dateutil-2.9.0.20260807-py3-none-any.whl", hash = "sha256:54aa3707350ed7a9cc0776fd2f6739679d6967d11b40150985e81edcb86df4db"}, + {file = "types_python_dateutil-2.9.0.20260807.tar.gz", hash = "sha256:e0b8a90d464c8684c66b7b8e4556d9074afdddcc56ca45323f0987134f9e7034"}, + @@ -1308,7 +1373,6 @@ version = "4.16.0" + description = "Backported and Experimental Type Hints for Python 3.9+" + optional = false + python-versions = ">=3.9" + -groups = ["main", "dev"] + files = [ + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, + @@ -1320,7 +1384,6 @@ version = "0.4.4" + description = "Runtime typing introspection tools" + optional = false + python-versions = ">=3.10" + -groups = ["main"] + files = [ + {file = "typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147"}, + {file = "typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47"}, + @@ -1335,8 +1398,6 @@ version = "48.0.0" + description = "A WebAssembly runtime powered by Wasmtime" + optional = true + python-versions = ">=3.9" + -groups = ["main"] + -markers = "extra == \"datastream\" or extra == \"rulesengine\"" + files = [ + {file = "wasmtime-48.0.0-py3-none-android_26_arm64_v8a.whl", hash = "sha256:a55abf132fe238b843a963c68cd1a30d8f686c1bc75d8fbf042d8b7a1d51ee36"}, + {file = "wasmtime-48.0.0-py3-none-android_26_x86_64.whl", hash = "sha256:d8e94276ff6c0c5ce73ee16ccbacb00b3512a4b3a664749380705d81ee06a23c"}, + @@ -1361,8 +1422,6 @@ version = "16.1.1" + description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" + optional = true + python-versions = ">=3.10" + -groups = ["main"] + -markers = "extra == \"datastream\"" + files = [ + {file = "websockets-16.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:49ae99bdfcae803a885c926bf14f886196e84925395bb3f568fef5c0f0979d7d"}, + {file = "websockets-16.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5bfd1ac19b1b9986a9c95a82d5e23a391ebb09e12c34d7be6094b86efcc35731"}, + @@ -1481,7 +1540,6 @@ version = "1.24.5" + description = "Yet another URL library" + optional = false + python-versions = ">=3.10" + -groups = ["main", "dev"] + files = [ + {file = "yarl-1.24.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750"}, + {file = "yarl-1.24.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2"}, + @@ -1588,7 +1646,6 @@ files = [ + {file = "yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7"}, + {file = "yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f"}, + ] + -markers = {main = "extra == \"aiohttp\""} + + [package.dependencies] + idna = ">=2.0" + @@ -1601,6 +1658,6 @@ datastream = ["wasmtime", "websockets"] + rulesengine = ["wasmtime"] + + [metadata] + -lock-version = "2.1" + +lock-version = "2.0" + python-versions = "^3.10" + -content-hash = "d12f2b0714d21d258fab37327fffd7eed95cbda40524b341fc8e0479072c5342" + +content-hash = "b97aece3e401780396eb371b240c4c82f59799ccece668843b9f37dd9b0af950" + diff --git a/src/schematic/leases/__init__.py b/src/schematic/leases/__init__.py + new file mode 100644 + index 0000000..6dcb083 + --- /dev/null + +++ b/src/schematic/leases/__init__.py + @@ -0,0 +1,73 @@ + +"""Client-mode credit leases: local lease slots, reservations, and their manager. + + + +Async only. Client-mode leases ride on DataStream, which this SDK offers on + +``AsyncSchematic`` alone, so there is no synchronous variant. + + + +The in-memory stores gate within one process; the Redis stores gate across + +pods and share their key layout with the Node SDK, so mixed fleets agree on + +every lease. + +""" + + + +from .check import CheckDataStream, CreditCheckDeps, check_with_lease + +from .lease_manager import CreditsWireClient, LeaseGrant, LeaseManager, LeaseWireClient + +from .lease_store import InMemoryLeaseStore, LeaseStore, lease_key + +from .redis_lease_store import RedisLeaseStore + +from .redis_reservation_store import RedisReservationStore + +from .reservation_store import InMemoryReservationStore, ReservationStore + +from .track import ( + + ReservationConsumeResult, + + build_reservation_track_event, + + consume_reservation_and_build_event, + + settled_quantity, + +) + +from .types import ( + + DEFAULT_LEASE_DURATION, + + DEFAULT_LEASE_SIZE, + + DEFAULT_LOW_WATER_MARK, + + DEFAULT_PREWARM_RESOLVE_TIMEOUT, + + DEFAULT_RESERVATION_TTL, + + DEFAULT_SWEEP_INTERVAL, + + Clock, + + LeaseConfig, + + LeaseConfigOverride, + + LeaseState, + + ReservationRecord, + + ResolvedLeaseConfig, + + is_valid_quantity, + + resolve_lease_config, + +) + + + +__all__ = [ + + "CheckDataStream", + + "Clock", + + "CreditCheckDeps", + + "CreditsWireClient", + + "DEFAULT_LEASE_DURATION", + + "DEFAULT_LEASE_SIZE", + + "DEFAULT_LOW_WATER_MARK", + + "DEFAULT_PREWARM_RESOLVE_TIMEOUT", + + "DEFAULT_RESERVATION_TTL", + + "DEFAULT_SWEEP_INTERVAL", + + "InMemoryLeaseStore", + + "InMemoryReservationStore", + + "LeaseConfig", + + "LeaseConfigOverride", + + "LeaseGrant", + + "LeaseManager", + + "LeaseState", + + "LeaseStore", + + "LeaseWireClient", + + "RedisLeaseStore", + + "RedisReservationStore", + + "ReservationConsumeResult", + + "ReservationRecord", + + "ReservationStore", + + "ResolvedLeaseConfig", + + "build_reservation_track_event", + + "check_with_lease", + + "consume_reservation_and_build_event", + + "is_valid_quantity", + + "lease_key", + + "resolve_lease_config", + + "settled_quantity", + +] + diff --git a/src/schematic/leases/check.py b/src/schematic/leases/check.py + new file mode 100644 + index 0000000..5793cfa + --- /dev/null + +++ b/src/schematic/leases/check.py + @@ -0,0 +1,526 @@ + +"""The client-mode check flow: probe the entitlement, lease, reserve, gate. + + + +One ``check()`` with usage runs the rules engine twice. The first run is a + +probe against the company's real balance that names the credit being metered; + +the second gates the call against the lease's local balance, after the credits + +have already been debited. The conformance vectors in ``conformance/vectors`` + +pin every step, and ``conformance/SPEC.md`` explains why each one is ordered + +the way it is. + +""" + + + +from __future__ import annotations + + + +import logging + +import time + +import uuid + +from dataclasses import dataclass + +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Optional, Protocol + + + +from ..types import ( + + EventBodyFlagCheck, + + RulesengineCheckFlagResult, + + RulesengineCompany, + + RulesengineFlag, + + RulesengineUser, + +) + +from .lease_manager import LeaseManager + +from .lease_store import LeaseStore + +from .reservation_store import ReservationStore + +from .types import Clock, ReservationRecord, is_valid_quantity + + + +if TYPE_CHECKING: + + from ..client import CheckFlagOptions, CheckOptions, CheckResult, Reservation + + + +logger = logging.getLogger(__name__) + + + +# The balance the fail-open evaluation substitutes for the metered credit: + +# large enough that the credit gate always passes, and still exact as a JSON + +# number, so the engine reads back what the SDK sent. This is Node's + +# Number.MAX_SAFE_INTEGER, which the vectors name "max_safe_integer". + +FAIL_OPEN_BALANCE = 2**53 - 1 + + + + + +class CheckDataStream(Protocol): + + """The slice of ``DataStreamClient`` a lease-bearing check touches. + + + + Narrow on purpose: it keeps this module off the DataStream client's wider + + surface, and lets the conformance runner script the flow without a socket. + + """ + + + + async def get_flag(self, flag_key: str) -> Optional[RulesengineFlag]: ... + + + + async def get_company(self, keys: Dict[str, str]) -> RulesengineCompany: ... + + + + async def get_user(self, keys: Dict[str, str]) -> Any: ... + + + + def evaluate_flag( + + self, flag: Any, company: Any, user: Any, options: Any = None + + ) -> RulesengineCheckFlagResult: ... + + + + + +@dataclass + +class CreditCheckDeps: + + """Everything a lease-bearing check draws on, gathered by the client.""" + + + + datastream: Optional[CheckDataStream] + + lease_store: LeaseStore + + reservations: ReservationStore + + manager: LeaseManager + + logger: logging.Logger + + # Report a flag_check event for a check this module resolved itself. The + + # plain check paths enqueue one per check, so without this a lease-gated + + # check would be invisible to flag-check analytics and company last-seen. + + # Fallback exits do not call it: the plain check they delegate to reports + + # its own. + + enqueue_flag_check: Callable[[EventBodyFlagCheck], Awaitable[None]] + + clock: Clock = time.time + + + + + +async def check_with_lease( + + deps: CreditCheckDeps, + + flag_key: str, + + company: Optional[Dict[str, str]], + + user: Optional[Dict[str, str]], + + options: "CheckOptions", + + fallback: Callable[[], Awaitable["CheckResult"]], + +) -> "CheckResult": + + """Gate one check against a local lease, returning a hold when it allows. + + + + ``fallback`` is the plain flag check. Every step that cannot resolve a + + credit to meter defers to it, since the plain check has its own degradation + + story and issues no hold. A step that *can* resolve the credit but cannot + + gate on it goes through ``on_acquire_failure`` instead. + + """ + + log = deps.logger + + mode = options.on_acquire_failure or "fail-closed" + + usage = options.usage + + + + # A malformed usage must never reach the stores, and the caller asked for a + + # contract for exactly this case, so resolve it through that rather than + + # letting it surface as an opaque reserve failure. + + if usage is None or not is_valid_quantity(usage): + + log.error( + + f"Lease check: invalid usage {usage!r} for flag {flag_key}; must be a finite, non-negative number" + + ) + + return await _emit_flag_check( + + deps, company, user, _static_failure_result(mode, flag_key, "invalid_usage", None) + + ) + + + + # Nothing to reserve. The plain check still carries the preflight, so every + + # rule evaluates normally; a 0-credit handle would only be a no-op. + + if usage == 0: + + log.debug(f"Lease check: usage is 0 for flag {flag_key}, nothing to reserve, using a plain check") + + return await fallback() + + + + datastream = deps.datastream + + if datastream is None: + + log.debug("Lease check: no DataStream, using a plain check") + + return await fallback() + + + + flag = await _load_flag(datastream, flag_key, log) + + if flag is None: + + log.debug(f"Lease check: no cached flag for {flag_key}, using a plain check") + + return await fallback() + + + + if not company: + + log.debug("Lease check: no company keys, using a plain check") + + return await fallback() + + + + # Resolve company and user the way a plain DataStream check does: cache + + # first, then a live fetch. Evaluating without an entity the caller named + + # would silently skip its targeted rules and overrides, so a miss defers to + + # the plain check instead. + + resolved_company = await _load_company(datastream, company, log) + + if resolved_company is None: + + return await fallback() + + + + resolved_user: Optional[RulesengineUser] = None + + if user: + + resolved_user = await _load_user(datastream, user, log) + + if resolved_user is None: + + return await fallback() + + + + # Entitlement-first resolution. The probe runs against the real balance + + # with no preflight: applying a credit cost to a lease-depleted server + + # balance could fail the credit condition, drop the engine to a + + # lower-priority rule, and hide the entitlement being looked for. + + try: + + probe = datastream.evaluate_flag(flag, resolved_company, resolved_user, None) + + except Exception as err: + + # A probe failure is a resolution miss, not the gate, and no hold + + # exists yet to cancel. + + log.warning(f"Lease check: entitlement probe failed for flag {flag_key} ({err}), using a plain check") + + return await fallback() + + + + entitlement = probe.entitlement + + if entitlement is None or entitlement.value_type != "credit": + + value_type = entitlement.value_type if entitlement is not None else "" + + log.debug( + + f"Lease check: flag {flag_key} matched a non-credit entitlement (value_type={value_type}), " + + "using a plain check, no reservation" + + ) + + return await fallback() + + + + credit_id = entitlement.credit_id + + consumption_rate = entitlement.consumption_rate or 0.0 + + # The caller's subtype wins; otherwise the entitlement names the metered + + # event. A credit entitlement with neither a resolvable subtype nor a + + # positive rate can never be billed, so it is not gateable. + + event_subtype = options.event_subtype or entitlement.event_subtype + + if not credit_id or consumption_rate <= 0 or not event_subtype: + + log.debug( + + f"Lease check: flag {flag_key} has an incomplete credit entitlement " + + f"(credit_id={credit_id or ''}, consumption_rate={consumption_rate}, " + + f"event_subtype={event_subtype or ''}), using a plain check" + + ) + + return await fallback() + + + + credit_cost = usage * consumption_rate + + + + async def failure(reason: str) -> "CheckResult": + + result = await _handle_lease_failure( + + datastream=datastream, + + log=log, + + mode=mode, + + flag_key=flag_key, + + reason=reason, + + flag=flag, + + company=resolved_company, + + user=resolved_user, + + credit_id=credit_id, + + options=options, + + ) + + return await _emit_flag_check( + + deps, + + company, + + user, + + result, + + company_id=resolved_company.id, + + user_id=resolved_user.id if resolved_user is not None else None, + + ) + + + + # The caller's per-check timeout governs the lease wire calls, the same way + + # it governs the plain check's. + + lease = await deps.manager.acquire_if_needed(resolved_company.id, credit_id, options.timeout) + + if lease is None: + + return await failure("lease_acquire_failed") + + + + # try_reserve is the atomic gate: check and debit in one step, returning + + # the post-debit balance so the pre-debit figure needs no second read. + + try: + + post_reserve_balance = await deps.lease_store.try_reserve(resolved_company.id, credit_id, credit_cost) + + if post_reserve_balance is None: + + # Pass the cost as required_credits so a single large request + + # extends even while the ratio sits above the water mark. + + await deps.manager.maybe_extend(resolved_company.id, credit_id, credit_cost, options.timeout) + + post_reserve_balance = await deps.lease_store.try_reserve(resolved_company.id, credit_id, credit_cost) + + except Exception as err: + + log.error(f"Lease check: reserve against {resolved_company.id}/{credit_id} failed: {err}") + + return await failure("lease_store_error") + + if post_reserve_balance is None: + + return await failure("insufficient_lease_balance") + + + + # Record the hold after the debit and before the gate. A crash between the + + # debit and this add leaks at most this one hold, reclaimed when the lease + + # expires server-side; recording first would instead leave a record with no + + # debit, which a later consume would refund into a double-spend. + + resolved_config = deps.manager.resolve_config(credit_id) + + record = ReservationRecord( + + id=str(uuid.uuid4()), + + lease_id=lease.lease_id, + + company_id=resolved_company.id, + + credit_type_id=credit_id, + + event_subtype=event_subtype, + + quantity_reserved=usage, + + credits_reserved=credit_cost, + + consumption_rate=consumption_rate, + + expires_at=deps.clock() + resolved_config.reservation_ttl, + + company=company, + + user=user, + + ) + + try: + + await deps.reservations.add(record) + + except Exception as err: + + log.error(f"Lease check: failed to persist reservation {record.id}: {err}") + + # Undo the debit rather than strand it until lease expiry. consume + + # claims whatever slice of the add landed and refunds it; a None says + + # nothing landed, so refund the debit directly. Both are pinned to this + + # lease. If the undo itself fails, accept the bounded leak: the slice + + # comes back at lease expiry, which beats risking a double refund. + + try: + + if await deps.reservations.consume(record.id, 0) is None: + + await deps.lease_store.refund(resolved_company.id, credit_id, credit_cost, lease.lease_id) + + except Exception as undo_err: + + log.warning( + + f"Lease check: could not undo the local debit for {record.id} ({undo_err}); " + + "the slice is reclaimed at lease expiry" + + ) + + return await failure("lease_store_error") + + + + # Gate against the lease's local view rather than the server's balance. The + + # substituted figure is the PRE-reservation balance (what try_reserve + + # returned plus what it debited, exact as of the debit), and credit_cost + + # tells the engine what this call costs, so it evaluates the same + + # arithmetic try_reserve just enforced, plus every non-credit rule. + + pre_reservation = post_reserve_balance + credit_cost + + substituted = _substitute_credit_balance(resolved_company, credit_id, pre_reservation) + + try: + + result = datastream.evaluate_flag( + + flag, substituted, resolved_user, _credit_cost_options(credit_id, credit_cost) + + ) + + except Exception as err: + + log.error(f"Lease check: rules evaluation failed for flag {flag_key}: {err}") + + # The engine itself is down, so there is no fail-open re-evaluation to + + # run: resolve the mode statically. + + await _cancel_reservation(deps.reservations, record, log) + + return await _emit_flag_check( + + deps, + + company, + + user, + + _static_failure_result(mode, flag_key, f"wasm_error: {err}", flag), + + company_id=resolved_company.id, + + user_id=resolved_user.id if resolved_user is not None else None, + + ) + + + + # Engine-evaluated exits report the engine's resolved ids, mirroring the + + # plain DataStream path's flag_check event. + + engine_company_id = result.company_id or resolved_company.id + + engine_user_id = result.user_id or (resolved_user.id if resolved_user is not None else None) + + + + if not result.value: + + await _cancel_reservation(deps.reservations, record, log) + + return await _emit_flag_check( + + deps, + + company, + + user, + + _check_result( + + allowed=False, + + value=False, + + reason=result.reason or "denied_by_engine", + + flag_key=result.flag_key or flag_key, + + entitlement=_entitlement(result), + + flag_id=result.flag_id, + + ), + + company_id=engine_company_id, + + user_id=engine_user_id, + + rule_id=result.rule_id, + + ) + + + + # Allowed against the substituted balance, so the hold stands. Top the + + # lease up in the background now that it has been drawn down. + + deps.manager.extend_in_background(resolved_company.id, credit_id) + + return await _emit_flag_check( + + deps, + + company, + + user, + + _check_result( + + allowed=True, + + value=True, + + reason=result.reason or "lease_reserved", + + flag_key=result.flag_key or flag_key, + + reservation=_public_reservation(record), + + entitlement=_entitlement(result), + + flag_id=result.flag_id, + + ), + + company_id=engine_company_id, + + user_id=engine_user_id, + + rule_id=result.rule_id, + + ) + + + + + +async def _emit_flag_check( + + deps: CreditCheckDeps, + + req_company: Optional[Dict[str, str]], + + req_user: Optional[Dict[str, str]], + + result: "CheckResult", + + *, + + company_id: Optional[str] = None, + + user_id: Optional[str] = None, + + rule_id: Optional[str] = None, + +) -> "CheckResult": + + """Report a lease-path resolution and pass the result straight through. + + + + Analytics must never change a verdict the caller is already acting on, so a + + failure here is logged and swallowed. + + """ + + try: + + await deps.enqueue_flag_check( + + EventBodyFlagCheck( + + flag_key=result.flag_key, + + value=result.value, + + reason=result.reason, + + error=result.error, + + flag_id=result.flag_id, + + company_id=company_id, + + user_id=user_id, + + rule_id=rule_id, + + req_company=req_company, + + req_user=req_user, + + ) + + ) + + except Exception as err: + + deps.logger.debug(f"Lease check: failed to report the flag_check event: {err}") + + return result + + + + + +async def _load_flag(datastream: CheckDataStream, flag_key: str, log: logging.Logger) -> Optional[RulesengineFlag]: + + try: + + return await datastream.get_flag(flag_key) + + except Exception as err: + + log.warning(f"Lease check: failed to load flag {flag_key}: {err}") + + return None + + + + + +async def _load_company( + + datastream: CheckDataStream, keys: Dict[str, str], log: logging.Logger + +) -> Optional[RulesengineCompany]: + + try: + + return await datastream.get_company(keys) + + except Exception as err: + + log.debug(f"Lease check: company fetch failed for keys {keys} ({err}), using a plain check") + + return None + + + + + +async def _load_user( + + datastream: CheckDataStream, keys: Dict[str, str], log: logging.Logger + +) -> Optional[RulesengineUser]: + + try: + + return await datastream.get_user(keys) + + except Exception as err: + + log.debug(f"Lease check: user fetch failed for keys {keys} ({err}), using a plain check") + + return None + + + + + +async def _handle_lease_failure( + + *, + + datastream: CheckDataStream, + + log: logging.Logger, + + mode: str, + + flag_key: str, + + reason: str, + + flag: RulesengineFlag, + + company: RulesengineCompany, + + user: Optional[RulesengineUser], + + credit_id: str, + + options: "CheckOptions", + +) -> "CheckResult": + + """Resolve a check that could not gate: acquire failed, store unreachable, + + or the lease is exhausted. + + + + fail-closed denies. fail-open means assume the credits are there, not skip + + the evaluation: the rules still run with the balance substituted to an + + effectively unlimited value, so plan targeting, overrides, and every + + non-credit condition still apply, and a company that is not entitled stays + + denied with the lease backend down. Only an error in that evaluation drops + + to a blanket allow. + + """ + + if mode == "fail-closed": + + return _static_failure_result(mode, flag_key, reason, flag) + + + + try: + + substituted = _substitute_credit_balance(company, credit_id, FAIL_OPEN_BALANCE) + + result = datastream.evaluate_flag(flag, substituted, user, _preflight_options(options)) + + except Exception as err: + + log.warning(f"Lease check: the fail-open evaluation failed ({err}); allowing") + + return _static_failure_result(mode, flag_key, reason, flag) + + return _check_result( + + allowed=result.value, + + value=result.value, + + reason=f"{result.reason or 'evaluated'} ({reason}_fail_open)", + + flag_key=result.flag_key or flag_key, + + entitlement=_entitlement(result), + + flag_id=result.flag_id or flag.id, + + error=reason, + + ) + + + + + +def _static_failure_result( + + mode: str, flag_key: str, reason: str, flag: Optional[RulesengineFlag] + +) -> "CheckResult": + + """Resolve a mode with no evaluation behind it: deny for fail-closed, + + blanket allow for fail-open. + + + + Used when the engine is the thing that failed, and when the fail-open + + evaluation itself errors. + + """ + + allowed = mode != "fail-closed" + + return _check_result( + + allowed=allowed, + + value=allowed, + + reason=f"{reason}_fail_open" if allowed else reason, + + flag_key=flag_key, + + flag_id=flag.id if flag is not None else None, + + error=reason, + + ) + + + + + +async def _cancel_reservation( + + reservations: ReservationStore, record: ReservationRecord, log: logging.Logger + +) -> None: + + """Claim the hold and refund all of it. Best effort: a failure leaves the + + hold for the sweeper or for lease expiry.""" + + try: + + await reservations.consume(record.id, 0) + + except Exception as err: + + log.warning( + + f"Lease check: failed to cancel reservation {record.id} ({err}); " + + "its hold is reclaimed by the sweeper or at lease expiry" + + ) + + + + + +def _substitute_credit_balance( + + company: RulesengineCompany, credit_id: str, balance: float + +) -> RulesengineCompany: + + balances = dict(company.credit_balances or {}) + + balances[credit_id] = balance + + return company.model_copy(update={"credit_balances": balances}) + + + + + +def _credit_cost_options(credit_id: str, credit_cost: float) -> "CheckFlagOptions": + + from ..client import CheckFlagOptions + + + + return CheckFlagOptions(credit_cost={credit_id: credit_cost}) + + + + + +def _preflight_options(options: "CheckOptions") -> Optional["CheckFlagOptions"]: + + from ..client import _check_options_to_flag_options + + + + return _check_options_to_flag_options(options) + + + + + +def _entitlement(result: RulesengineCheckFlagResult) -> Optional[Any]: + + if result.entitlement is None: + + return None + + from ..types import FeatureEntitlement + + + + return FeatureEntitlement.model_validate(result.entitlement.model_dump()) + + + + + +def _public_reservation(record: ReservationRecord) -> "Reservation": + + """The caller's handle on a hold carved out of a lease.""" + + import datetime as dt + + + + from ..client import Reservation + + + + return Reservation( + + id=record.id, + + lease_id=record.lease_id, + + mode="client", + + company_id=record.company_id, + + credit_type_id=record.credit_type_id, + + event_subtype=record.event_subtype, + + quantity_reserved=record.quantity_reserved, + + credits_reserved=record.credits_reserved, + + consumption_rate=record.consumption_rate, + + expires_at=dt.datetime.fromtimestamp(record.expires_at, tz=dt.timezone.utc), + + company=record.company, + + user=record.user, + + ) + + + + + +def _check_result(**fields: Any) -> "CheckResult": + + """CheckResult is defined on the client, which imports this module, so the + + import waits until call time to keep the cycle from closing at import.""" + + from ..client import CheckResult + + + + return CheckResult(**fields) + diff --git a/src/schematic/leases/lease_manager.py b/src/schematic/leases/lease_manager.py + new file mode 100644 + index 0000000..141b93b + --- /dev/null + +++ b/src/schematic/leases/lease_manager.py + @@ -0,0 +1,452 @@ + +"""Lease lifecycle against the server: acquire, extend, release, sweep. + + + +Every path here resolves rather than raises. The manager's callers route a + +missing lease through their fail-open/fail-closed handling, and several calls + +are made fire-and-forget, where a raised exception would surface as an + +unretrieved task exception instead. + +""" + + + +from __future__ import annotations + + + +import asyncio + +import datetime as dt + +import logging + +import time + +from dataclasses import dataclass + +from typing import Any, Awaitable, Dict, Optional, Protocol, Set + + + +from .lease_store import LeaseStore, lease_key + +from .reservation_store import ReservationStore + +from .types import ( + + DEFAULT_SWEEP_INTERVAL, + + Clock, + + LeaseConfig, + + LeaseState, + + ResolvedLeaseConfig, + + resolve_lease_config, + +) + + + +logger = logging.getLogger(__name__) + + + + + +@dataclass + +class LeaseGrant: + + """What the server says a lease is, after an acquire or an extend.""" + + + + lease_id: str + + company_id: str + + credit_type_id: str + + # The server-authoritative TOTAL, not the increment an extend asked for. + + granted_amount: float + + expires_at: float + + + + + +class LeaseWireClient(Protocol): + + """The three lease calls the manager makes. + + + + Narrow on purpose: it keeps the manager independent of the generated + + client's request and response models, and lets tests script the server. + + """ + + + + async def acquire( + + self, + + company_id: str, + + credit_type_id: str, + + requested_amount: float, + + expires_at: float, + + timeout: Optional[float] = None, + + ) -> LeaseGrant: ... + + + + async def extend( + + self, + + lease_id: str, + + additional_amount: float, + + expires_at: float, + + timeout: Optional[float] = None, + + ) -> LeaseGrant: ... + + + + async def release(self, lease_id: str) -> None: ... + + + + + +class CreditsWireClient: + + """Adapter over the generated async credits client (``AsyncCreditsClient``).""" + + + + def __init__(self, credits_client: Any, *, request_options: Optional[Any] = None) -> None: + + self._credits = credits_client + + self._request_options = request_options + + + + def _options(self, timeout: Optional[float]) -> Optional[Any]: + + """The caller's per-check timeout wins over the client-wide options, + + which is what a caller asking for one on this check means.""" + + if timeout is None: + + return self._request_options + + return {"timeout": timeout} + + + + async def acquire( + + self, + + company_id: str, + + credit_type_id: str, + + requested_amount: float, + + expires_at: float, + + timeout: Optional[float] = None, + + ) -> LeaseGrant: + + response = await self._credits.acquire_credit_lease( + + company_id=company_id, + + credit_type_id=credit_type_id, + + requested_amount=requested_amount, + + expires_at=_to_datetime(expires_at), + + request_options=self._options(timeout), + + ) + + return _grant_from_response(response) + + + + async def extend( + + self, + + lease_id: str, + + additional_amount: float, + + expires_at: float, + + timeout: Optional[float] = None, + + ) -> LeaseGrant: + + response = await self._credits.extend_credit_lease( + + lease_id, + + additional_amount=additional_amount, + + expires_at=_to_datetime(expires_at), + + request_options=self._options(timeout), + + ) + + return _grant_from_response(response) + + + + async def release(self, lease_id: str) -> None: + + await self._credits.release_credit_lease(lease_id, request_options=self._request_options) + + + + + +class LeaseManager: + + """Owns lease rows for one client: acquire on first use or after expiry, + + extend when the local view dips below the water mark, release on close. + + + + Acquire and extend each get their own best-effort single-flight map keyed + + by slot. Best-effort because callers racing ahead of the registration can + + still issue duplicate wire calls, which is safe: the server is idempotent + + for an active slot, ``replace`` keeps the first live lease, and ``extend`` + + reconciles to a total. + + """ + + + + def __init__( + + self, + + wire_client: LeaseWireClient, + + lease_store: LeaseStore, + + *, + + reservation_store: Optional[ReservationStore] = None, + + config: Optional[LeaseConfig] = None, + + clock: Clock = time.time, + + ) -> None: + + self._wire = wire_client + + self._lease_store = lease_store + + self._reservation_store = reservation_store + + self._config = config or LeaseConfig() + + self._clock = clock + + # Kept separate so an in-flight extend can never satisfy an acquire, + + # or the other way round. + + self._inflight_acquire: Dict[str, "asyncio.Future[Optional[LeaseState]]"] = {} + + self._inflight_extend: Dict[str, "asyncio.Future[Optional[LeaseState]]"] = {} + + self._background: Set["asyncio.Task[None]"] = set() + + self._sweep_task: Optional["asyncio.Task[None]"] = None + + self._stopped = False + + + + def resolve_config(self, credit_type_id: str) -> ResolvedLeaseConfig: + + return resolve_lease_config(self._config, None, credit_type_id) + + + + @property + + def sweep_interval(self) -> float: + + return self._config.sweep_interval or DEFAULT_SWEEP_INTERVAL + + + + async def acquire_if_needed( + + self, company_id: str, credit_type_id: str, timeout: Optional[float] = None + + ) -> Optional[LeaseState]: + + """The slot's live lease, acquiring one over the wire if none is live. + + + + ``timeout`` governs the wire call this caller starts. A caller that + + joins an in-flight acquire rides the first caller's timeout, since + + there is one shared call to time out. + + """ + + try: + + existing = await self._lease_store.get(company_id, credit_type_id) + + except Exception as err: + + logger.error("Failed to read lease store for %s/%s: %s", company_id, credit_type_id, err) + + return None + + if existing is not None and existing.expires_at > self._clock(): + + return existing + + # An expired (or absent) slot is left for `replace` to overwrite: it + + # guards on expiry and writes atomically. Dropping the stale row first + + # would be a separate, non-atomic op that can interleave between a + + # sibling pod's read and its replace, clobbering a lease that pod just + + # installed. Reading a stale entry in the gap is harmless, since every + + # path that acts on a lease re-guards on expiry. + + + + key = lease_key(company_id, credit_type_id) + + inflight = self._inflight_acquire.get(key) + + if inflight is not None: + + return await asyncio.shield(inflight) + + return await self._single_flight( + + self._inflight_acquire, key, self._acquire(company_id, credit_type_id, timeout) + + ) + + + + async def _acquire( + + self, company_id: str, credit_type_id: str, timeout: Optional[float] = None + + ) -> Optional[LeaseState]: + + resolved = self.resolve_config(credit_type_id) + + try: + + grant = await self._wire.acquire( + + company_id, + + credit_type_id, + + resolved.lease_size, + + self._clock() + resolved.lease_duration, + + timeout, + + ) + + wrote = await self._lease_store.replace( + + lease_id=grant.lease_id, + + company_id=grant.company_id or company_id, + + credit_type_id=grant.credit_type_id or credit_type_id, + + granted_amount=grant.granted_amount, + + expires_at=grant.expires_at, + + ) + + if wrote: + + return await self._lease_store.get(company_id, credit_type_id) + + + + # A sibling holds the slot with a live lease, or the slot's expired + + # row was reconciled in place. The server is idempotent for an + + # active slot, so a racing acquire is normally handed back the SAME + + # lease the sibling installed, and releasing it would pull the + + # shared lease out from under every pod. Only a *different* lease + + # is a redundant hold nobody will draw on, so only that one is + + # released. An empty slot (expired in the gap) releases nothing + + # either: this lease is likely what the next acquire is handed. + + current = await self._lease_store.get(company_id, credit_type_id) + + if current is not None and current.lease_id != grant.lease_id: + + logger.debug( + + "Lost acquire race for %s/%s; releasing redundant lease %s", + + company_id, + + credit_type_id, + + grant.lease_id, + + ) + + self._spawn(self._release(grant.lease_id)) + + return current + + except Exception as err: + + logger.error("Failed to acquire credit lease for %s/%s: %s", company_id, credit_type_id, err) + + return None + + + + async def maybe_extend( + + self, + + company_id: str, + + credit_type_id: str, + + required_credits: Optional[float] = None, + + timeout: Optional[float] = None, + + ) -> Optional[LeaseState]: + + """Extend the slot's lease when the local view warrants it. + + + + Triggered by either the low-water-mark ratio (steady-state refresh) or + + a ``required_credits`` hint above the local remaining (a check just + + failed a reserve of that size). + + """ + + try: + + entry = await self._lease_store.get(company_id, credit_type_id) + + except Exception as err: + + logger.warning("Failed to read lease store for %s/%s: %s", company_id, credit_type_id, err) + + return None + + if entry is None: + + return None + + # Never extend an expired lease: the server treats it as released and + + # has already refunded its remainder, so the only correct move is a + + # fresh acquire on the next check. + + if entry.expires_at <= self._clock(): + + return None + + resolved = self.resolve_config(credit_type_id) + + ratio = entry.local_remaining_credits / max(entry.granted_amount, 1) + + below_watermark = ratio <= resolved.low_water_mark + + below_required = required_credits is not None and entry.local_remaining_credits < required_credits + + if not below_watermark and not below_required: + + return entry + + + + key = lease_key(company_id, credit_type_id) + + inflight = self._inflight_extend.get(key) + + if inflight is not None: + + return await asyncio.shield(inflight) + + return await self._single_flight( + + self._inflight_extend, key, self._extend(entry, resolved, required_credits, timeout) + + ) + + + + async def _extend( + + self, + + entry: LeaseState, + + resolved: ResolvedLeaseConfig, + + required_credits: Optional[float], + + timeout: Optional[float] = None, + + ) -> Optional[LeaseState]: + + # Size the extend to cover the request that triggered it: a single + + # check needing more than remaining plus one tranche would otherwise + + # fail its post-extend retry forever, however much balance the server + + # has. The steady-state path keeps asking for the configured tranche. + + shortfall = (required_credits - entry.local_remaining_credits) if required_credits is not None else 0.0 + + try: + + grant = await self._wire.extend( + + entry.lease_id, + + max(resolved.lease_size, shortfall), + + self._clock() + resolved.lease_duration, + + timeout, + + ) + + # Reconcile to the server's authoritative TOTAL, with the store + + # computing the delta against its own current total: per-process + + # single-flight does not cover sibling pods. Pinned to the lease + + # the server extended, so an expiry mid-call cannot mint the delta + + # onto a successor. + + await self._lease_store.extend( + + entry.company_id, + + entry.credit_type_id, + + grant.granted_amount, + + grant.expires_at, + + entry.lease_id, + + ) + + return await self._lease_store.get(entry.company_id, entry.credit_type_id) + + except Exception as err: + + logger.warning("Failed to extend credit lease %s: %s", entry.lease_id, err) + + return None + + + + def extend_in_background(self, company_id: str, credit_type_id: str) -> None: + + """Kick off a water-mark extend without waiting for it. + + + + A check that just drew the lease down should not pay for the top-up, so + + the extend runs as tracked background work and never raises into the + + caller. + + """ + + + + async def run() -> None: + + await self.maybe_extend(company_id, credit_type_id) + + + + self._spawn(run()) + + + + async def release_all_local_leases(self) -> None: + + """Release every live lease this process exclusively holds. + + + + Only a per-process store answers ``list_leases``; a shared backend + + returns ``None`` and is skipped, since sibling pods still draw on those + + leases. Expired leases are skipped too: the server already swept them. + + Best-effort, with failures falling back to server-side expiry. + + """ + + try: + + entries = self._lease_store.list_leases() + + except Exception as err: + + logger.warning("Failed to enumerate leases on close: %s", err) + + return + + if not entries: + + return + + now = self._clock() + + for entry in entries: + + if entry.expires_at <= now: + + continue + + try: + + await self._wire.release(entry.lease_id) + + await self._lease_store.drop(entry.company_id, entry.credit_type_id) + + logger.debug("Released credit lease %s on close", entry.lease_id) + + except Exception as err: + + logger.warning( + + "Failed to release credit lease %s on close (it will expire server-side): %s", + + entry.lease_id, + + err, + + ) + + + + def start_sweep(self) -> None: + + """Run the expired-reservation sweep on an interval. Safe to call twice.""" + + if self._reservation_store is None or self._sweep_task is not None or self._stopped: + + return + + try: + + loop = asyncio.get_running_loop() + + except RuntimeError: + + logger.debug("No running event loop; the reservation sweep stays off") + + return + + self._sweep_task = loop.create_task(self._sweep_loop()) + + + + async def _sweep_loop(self) -> None: + + store = self._reservation_store + + assert store is not None + + while True: + + await asyncio.sleep(self.sweep_interval) + + try: + + await store.sweep_expired() + + except asyncio.CancelledError: + + raise + + except Exception as err: + + # Keep the loop alive: a sweep failure is transient (a Redis + + # blip), and the next tick retries. + + logger.debug("Reservation sweep failed: %s", err) + + + + def stop(self) -> None: + + """Cancel the sweep loop. Pending releases are left to finish.""" + + self._stopped = True + + if self._sweep_task is not None: + + self._sweep_task.cancel() + + self._sweep_task = None + + + + async def _single_flight( + + self, + + registry: Dict[str, "asyncio.Future[Optional[LeaseState]]"], + + key: str, + + coro: Awaitable[Optional[LeaseState]], + + ) -> Optional[LeaseState]: + + task = asyncio.ensure_future(coro) + + registry[key] = task + + try: + + return await asyncio.shield(task) + + finally: + + if registry.get(key) is task: + + del registry[key] + + + + async def _release(self, lease_id: str) -> None: + + try: + + await self._wire.release(lease_id) + + except Exception as err: + + logger.warning("Failed to release redundant credit lease %s: %s", lease_id, err) + + + + def _spawn(self, coro: Awaitable[None]) -> None: + + """Run a fire-and-forget step, holding a reference so it is not collected.""" + + try: + + task = asyncio.get_running_loop().create_task(_never_raises(coro)) + + except RuntimeError: + + logger.debug("No running event loop; skipping background lease work") + + return + + self._background.add(task) + + task.add_done_callback(self._background.discard) + + + + async def _drain_background(self) -> None: + + """Wait out pending fire-and-forget work. For tests and close paths.""" + + while self._background: + + await asyncio.gather(*list(self._background), return_exceptions=True) + + + + + +async def _never_raises(coro: Awaitable[None]) -> None: + + try: + + await coro + + except asyncio.CancelledError: + + raise + + except Exception as err: + + logger.debug("Background lease work failed: %s", err) + + + + + +def _to_datetime(epoch_seconds: float) -> dt.datetime: + + return dt.datetime.fromtimestamp(epoch_seconds, tz=dt.timezone.utc) + + + + + +def _epoch_seconds(value: dt.datetime) -> float: + + # A naive timestamp from the API is UTC; reading it as local time would + + # shift every expiry by the pod's offset. + + if value.tzinfo is None: + + return value.replace(tzinfo=dt.timezone.utc).timestamp() + + return value.timestamp() + + + + + +def _grant_from_response(response: Any) -> LeaseGrant: + + data = response.data + + return LeaseGrant( + + lease_id=data.id, + + company_id=data.company_id, + + credit_type_id=data.credit_type_id, + + granted_amount=float(data.granted_amount), + + expires_at=_epoch_seconds(data.expires_at), + + ) + diff --git a/src/schematic/leases/lease_store.py b/src/schematic/leases/lease_store.py + new file mode 100644 + index 0000000..0742ff7 + --- /dev/null + +++ b/src/schematic/leases/lease_store.py + @@ -0,0 +1,270 @@ + +"""Lease slot storage: the contract, plus the per-process in-memory backend. + + + +At most one lease occupies a ``(company_id, credit_type_id)`` slot. Every + +mutation is atomic per slot; ``RedisLeaseStore`` gets that from single-key Lua, + +this one from a per-slot ``asyncio.Lock``. + +""" + + + +from __future__ import annotations + + + +import abc + +import asyncio + +import math + +import time + +from contextlib import asynccontextmanager + +from typing import AsyncIterator, Dict, List, Optional, Tuple + + + +from .types import Clock, LeaseState + + + + + +def lease_key(company_id: str, credit_type_id: str) -> str: + + return f"{company_id}:{credit_type_id}" + + + + + +class LeaseStore(abc.ABC): + + """Backing store for lease slots, shared by the in-memory and Redis backends.""" + + + + @abc.abstractmethod + + async def get(self, company_id: str, credit_type_id: str) -> Optional[LeaseState]: + + """Snapshot of the slot, expired or not. Callers re-guard on expiry.""" + + + + @abc.abstractmethod + + async def replace( + + self, + + *, + + lease_id: str, + + company_id: str, + + credit_type_id: str, + + granted_amount: float, + + expires_at: float, + + ) -> bool: + + """Install a fresh lease at its full grant, if the slot is free to take. + + + + A *live* lease holds the slot even when it carries a different id (a + + sibling pod won the acquire race): its already-debited balance wins and + + this reports ``False``. An *expired* row carrying the SAME id is not + + rewritten either, since that would reset the balance and erase debits + + whose reservations are still open; it is reconciled like an extend + + (granted to the incoming total, expiry forward only, balance untouched) + + and also reports ``False``. Returns ``True`` only when a fresh row was + + written, which is what tells the manager whether the lease it just + + acquired is redundant. + + """ + + + + @abc.abstractmethod + + async def try_reserve(self, company_id: str, credit_type_id: str, credits: float) -> Optional[float]: + + """Atomically check and debit, returning the post-debit balance. + + + + ``None`` when there is no lease, it has expired, the balance is short, + + or ``credits`` is not a finite non-negative number. Returning the + + balance (rather than a bool) lets the caller derive the pre-debit + + figure as ``returned + credits`` without a racy follow-up read. + + """ + + + + @abc.abstractmethod + + async def refund( + + self, + + company_id: str, + + credit_type_id: str, + + credits: float, + + pin_lease_id: Optional[str] = None, + + ) -> None: + + """Return credits to the slot's balance, clamped at the granted amount. + + + + With ``pin_lease_id``, the refund applies only while the slot still + + holds that lease: a hold carved out of an expired lease must never + + inflate its successor, whose grant the server already issued whole. + + """ + + + + @abc.abstractmethod + + async def extend( + + self, + + company_id: str, + + credit_type_id: str, + + granted_total: float, + + new_expires_at: Optional[float] = None, + + pin_lease_id: Optional[str] = None, + + ) -> None: + + """Reconcile the slot to the server-authoritative total. + + + + The delta is computed inside the store against the currently stored + + total, never from a caller-held pre-wire-call read: two pods extending + + concurrently from the same stale read would each apply a delta and mint + + phantom credits. A total a sibling already applied is a no-op, so + + applies converge in any order. Expiry only ever moves forward. + + """ + + + + @abc.abstractmethod + + async def drop(self, company_id: str, credit_type_id: str) -> None: + + """Remove the slot entry, after a remote release.""" + + + + def list_leases(self) -> Optional[List[LeaseState]]: + + """Every lease this store holds, or ``None`` when it cannot enumerate. + + + + Only a per-process store answers: its leases are exclusively this + + process's, so releasing them on close is safe. A shared backend must + + never enumerate and release, since sibling pods still draw on those + + leases. + + """ + + return None + + + + + +class _SlotLocks: + + """Per-slot mutual exclusion, refcounted so idle slots do not accumulate.""" + + + + def __init__(self) -> None: + + self._locks: Dict[str, Tuple[asyncio.Lock, int]] = {} + + + + @asynccontextmanager + + async def hold(self, key: str) -> AsyncIterator[None]: + + lock, waiters = self._locks.get(key, (asyncio.Lock(), 0)) + + self._locks[key] = (lock, waiters + 1) + + try: + + async with lock: + + yield + + finally: + + held, count = self._locks[key] + + if count <= 1: + + del self._locks[key] + + else: + + self._locks[key] = (held, count - 1) + + + + + +class InMemoryLeaseStore(LeaseStore): + + """Per-process lease slots. Single-pod gating only. + + + + Swap in ``RedisLeaseStore`` to gate across pods; both implement the same + + contract. + + """ + + + + def __init__(self, *, clock: Clock = time.time) -> None: + + self._clock = clock + + self._leases: Dict[str, LeaseState] = {} + + self._locks = _SlotLocks() + + + + async def get(self, company_id: str, credit_type_id: str) -> Optional[LeaseState]: + + entry = self._leases.get(lease_key(company_id, credit_type_id)) + + return _copy(entry) if entry else None + + + + async def replace( + + self, + + *, + + lease_id: str, + + company_id: str, + + credit_type_id: str, + + granted_amount: float, + + expires_at: float, + + ) -> bool: + + key = lease_key(company_id, credit_type_id) + + async with self._locks.hold(key): + + existing = self._leases.get(key) + + if existing is not None and existing.expires_at > self._clock(): + + return False + + if existing is not None and existing.lease_id == lease_id: + + # The same lease coming back over its own expired row: a stale + + # acquire response for a lease the idempotent server also + + # handed a racing sibling, which may since have extended it. + + add = granted_amount - existing.granted_amount + + if add > 0: + + existing.granted_amount = granted_amount + + existing.local_remaining_credits += add + + if expires_at > existing.expires_at: + + existing.expires_at = expires_at + + return False + + self._leases[key] = LeaseState( + + lease_id=lease_id, + + company_id=company_id, + + credit_type_id=credit_type_id, + + granted_amount=granted_amount, + + local_remaining_credits=granted_amount, + + expires_at=expires_at, + + ) + + return True + + + + async def try_reserve(self, company_id: str, credit_type_id: str, credits: float) -> Optional[float]: + + # NaN passes every comparison below, and a NaN balance would approve + + # every later reserve, so it never reaches the arithmetic. + + if not is_finite_non_negative(credits): + + return None + + key = lease_key(company_id, credit_type_id) + + async with self._locks.hold(key): + + entry = self._leases.get(key) + + if entry is None: + + return None + + if entry.expires_at <= self._clock(): + + return None + + if entry.local_remaining_credits < credits: + + return None + + entry.local_remaining_credits -= credits + + return entry.local_remaining_credits + + + + async def refund( + + self, + + company_id: str, + + credit_type_id: str, + + credits: float, + + pin_lease_id: Optional[str] = None, + + ) -> None: + + if not is_finite_non_negative(credits) or credits <= 0: + + return + + key = lease_key(company_id, credit_type_id) + + async with self._locks.hold(key): + + entry = self._leases.get(key) + + if entry is None: + + return + + if pin_lease_id is not None and entry.lease_id != pin_lease_id: + + return + + entry.local_remaining_credits = min( + + entry.local_remaining_credits + credits, + + entry.granted_amount, + + ) + + + + async def extend( + + self, + + company_id: str, + + credit_type_id: str, + + granted_total: float, + + new_expires_at: Optional[float] = None, + + pin_lease_id: Optional[str] = None, + + ) -> None: + + key = lease_key(company_id, credit_type_id) + + async with self._locks.hold(key): + + entry = self._leases.get(key) + + if entry is None: + + return + + if pin_lease_id is not None and entry.lease_id != pin_lease_id: + + return + + add = granted_total - entry.granted_amount + + if add > 0: + + entry.granted_amount = granted_total + + entry.local_remaining_credits += add + + if new_expires_at is not None and new_expires_at > entry.expires_at: + + entry.expires_at = new_expires_at + + + + async def drop(self, company_id: str, credit_type_id: str) -> None: + + key = lease_key(company_id, credit_type_id) + + async with self._locks.hold(key): + + self._leases.pop(key, None) + + + + def list_leases(self) -> Optional[List[LeaseState]]: + + return [_copy(entry) for entry in self._leases.values()] + + + + + +def _copy(entry: LeaseState) -> LeaseState: + + return LeaseState( + + lease_id=entry.lease_id, + + company_id=entry.company_id, + + credit_type_id=entry.credit_type_id, + + granted_amount=entry.granted_amount, + + local_remaining_credits=entry.local_remaining_credits, + + expires_at=entry.expires_at, + + ) + + + + + +def is_finite_non_negative(value: float) -> bool: + + """A credit amount fit for arithmetic: finite, and not negative.""" + + try: + + number = float(value) + + except (TypeError, ValueError): + + return False + + return math.isfinite(number) and number >= 0 + diff --git a/src/schematic/leases/redis_lease_store.py b/src/schematic/leases/redis_lease_store.py + new file mode 100644 + index 0000000..0accf65 + --- /dev/null + +++ b/src/schematic/leases/redis_lease_store.py + @@ -0,0 +1,349 @@ + +"""Redis-backed lease slots: one hash per slot, mutated by single-key Lua. + + + +The key layout, hash field names (camelCase), millisecond instants, Lua + +scripts, and TTL grace windows match the Node SDK exactly, so Node and Python + +pods can share one Redis and agree on every slot. + +""" + + + +from __future__ import annotations + + + +import time + +from typing import Any, Dict, List, Optional + + + +from .lease_store import LeaseStore, is_finite_non_negative, lease_key + +from .types import DEFAULT_LEASE_DURATION, Clock, LeaseState + + + +DEFAULT_KEY_PREFIX = "schematic:" + +LEASE_KEY_NAMESPACE = "credit-lease:" + +# How long after the declared expiry the row survives before Redis evicts it. + +# Gives the sweeper a window to refund expired reservations before the lease + +# state underneath them disappears. + +LEASE_TTL_GRACE_MS = 60_000 + + + +# Every Lua script below touches exactly ONE key (the lease hash), keeping + +# them safe under Redis Cluster (multi-key scripts spanning slots raise + +# CROSSSLOT). Only the lease hash needs atomic mutation; cross-key + +# bookkeeping uses ordinary single-key commands. + +# + +# Expiry is decided against the *Redis server's* clock (`redis.call('TIME')`), + +# not the calling pod's: with many pods sharing one lease, local clock skew + +# would let pods disagree on whether the lease is live. The `LEASE_NOW_MS` + +# snippet converts TIME to integer milliseconds (matching the stored + +# `expiresAt`); `redis.replicate_commands()` first, so the non-deterministic + +# TIME read is allowed alongside writes on Redis 5/6. + +LEASE_NOW_MS = """ + +redis.replicate_commands() + +local t = redis.call('TIME') + +local now = (tonumber(t[1]) * 1000) + math.floor(tonumber(t[2]) / 1000) + +""" + + + +# Atomic `replace`. Writes the lease hash only when the slot is empty or the + +# existing lease has expired. Returns 1 on write, 0 if a *live* lease already + +# occupies the slot, even one with a different leaseId, e.g. installed by a + +# sibling instance that raced this acquire. An expired row with the SAME + +# leaseId is reconciled like an extend instead of rewritten, which would reset + +# the balance and erase debits whose reservations are still open. + +REPLACE_SCRIPT = ( + + LEASE_NOW_MS + + + """ + +local existing_id = redis.call('HGET', KEYS[1], 'leaseId') + +local existing_expiry = tonumber(redis.call('HGET', KEYS[1], 'expiresAt') or '0') + +local new_id = ARGV[1] + +local new_granted = ARGV[2] + +local new_expiry = tonumber(ARGV[3]) + +local grace = tonumber(ARGV[4]) + + + +if existing_id and existing_expiry > now then + + return 0 + +end + + + +if existing_id == new_id then + + local granted = tonumber(redis.call('HGET', KEYS[1], 'grantedAmount') or '0') + + local add = tonumber(new_granted) - granted + + if add > 0 then + + local remaining = tonumber(redis.call('HGET', KEYS[1], 'localRemainingCredits') or '0') + + redis.call('HSET', KEYS[1], + + 'grantedAmount', new_granted, + + 'localRemainingCredits', tostring(remaining + add)) + + end + + if new_expiry > existing_expiry then + + redis.call('HSET', KEYS[1], 'expiresAt', ARGV[3]) + + redis.call('PEXPIREAT', KEYS[1], new_expiry + grace) + + end + + return 0 + +end + + + +redis.call('DEL', KEYS[1]) + +redis.call('HSET', KEYS[1], + + 'leaseId', new_id, + + 'companyId', ARGV[5], + + 'creditTypeId', ARGV[6], + + 'grantedAmount', new_granted, + + 'localRemainingCredits', new_granted, + + 'expiresAt', ARGV[3]) + +redis.call('PEXPIREAT', KEYS[1], new_expiry + grace) + +return 1 + +""" + +) + + + +# Atomic check-and-decrement on `localRemainingCredits`. Returns the post-debit + +# balance as a string (a Lua number reply truncates to integer, which would + +# corrupt fractional credit costs); nil if there is no lease, the lease has + +# expired, or there is insufficient remaining. The expiry guard compares + +# against the Redis server clock, so a reserve against an expired-but-not-yet- + +# evicted row during the TTL grace window is rejected. + +TRY_RESERVE_SCRIPT = ( + + LEASE_NOW_MS + + + """ + +local raw = redis.call('HGET', KEYS[1], 'localRemainingCredits') + +if not raw then return false end + +local expiry = tonumber(redis.call('HGET', KEYS[1], 'expiresAt') or '0') + +if expiry <= now then return false end + +local remaining = tonumber(raw) + +local requested = tonumber(ARGV[1]) + +if remaining < requested then return false end + +local new_remaining = remaining - requested + +redis.call('HSET', KEYS[1], 'localRemainingCredits', tostring(new_remaining)) + +return tostring(new_remaining) + +""" + +) + + + +# Refund credits, clamped at `grantedAmount`. ARGV[2], when non-empty, pins the + +# refund to a specific leaseId: if the slot now holds a different lease, the + +# refund is dropped: the expired lease's unspent remainder was already + +# returned to the company balance server-side, so crediting the successor would + +# mint phantom credits. + +REFUND_SCRIPT = """ + +local raw_remaining = redis.call('HGET', KEYS[1], 'localRemainingCredits') + +if not raw_remaining then return 0 end + +local required_lease = ARGV[2] + +if required_lease and required_lease ~= '' then + + local current_lease = redis.call('HGET', KEYS[1], 'leaseId') + + if current_lease ~= required_lease then return 0 end + +end + +local remaining = tonumber(raw_remaining) + +local granted = tonumber(redis.call('HGET', KEYS[1], 'grantedAmount') or '0') + +local refund = tonumber(ARGV[1]) + +local new_balance = remaining + refund + +if new_balance > granted then new_balance = granted end + +redis.call('HSET', KEYS[1], 'localRemainingCredits', tostring(new_balance)) + +return 1 + +""" + + + +# Reconcile the lease to the server-authoritative grantedAmount total + +# (ARGV[1]), crediting the difference to localRemainingCredits. The delta is + +# computed HERE, atomically against the hash's current total, never by the + +# caller from a pre-wire-call read: two pods extending the same shared lease + +# concurrently would each apply a delta against the same stale read and mint + +# phantom credits. Reconciling to the absolute total converges regardless of + +# arrival order. Expiry only ever moves forward. ARGV[4], when non-empty, pins + +# the extend to a leaseId, mirroring REFUND_SCRIPT. + +EXTEND_SCRIPT = """ + +local raw_granted = redis.call('HGET', KEYS[1], 'grantedAmount') + +if not raw_granted then return 0 end + +local required_lease = ARGV[4] + +if required_lease and required_lease ~= '' then + + local current_lease = redis.call('HGET', KEYS[1], 'leaseId') + + if current_lease ~= required_lease then return 0 end + +end + +local granted = tonumber(raw_granted) + +local target = tonumber(ARGV[1]) + +local add = target - granted + +if add > 0 then + + local remaining = tonumber(redis.call('HGET', KEYS[1], 'localRemainingCredits') or '0') + + redis.call('HSET', KEYS[1], + + 'grantedAmount', tostring(target), + + 'localRemainingCredits', tostring(remaining + add)) + +end + +local new_expiry = tonumber(ARGV[2]) + +local grace = tonumber(ARGV[3]) + +local current_expiry = tonumber(redis.call('HGET', KEYS[1], 'expiresAt') or '0') + +if new_expiry > current_expiry then + + redis.call('HSET', KEYS[1], 'expiresAt', ARGV[2]) + + redis.call('PEXPIREAT', KEYS[1], new_expiry + grace) + +end + +return 1 + +""" + + + + + +class LuaScript: + + """One Lua script, run by EVALSHA with a lazy SCRIPT LOAD and an EVAL fallback. + + + + A Redis that has dropped its script cache (restart, SCRIPT FLUSH) answers + + NOSCRIPT; the fallback re-sends the body and re-loads it on the next call. + + """ + + + + def __init__(self, body: str) -> None: + + self.body = body + + self._sha: Optional[str] = None + + + + async def run(self, client: Any, keys: List[str], args: List[str]) -> Any: + + if self._sha is None: + + self._sha = to_str(await client.script_load(self.body)) + + try: + + return await client.evalsha(self._sha, len(keys), *keys, *args) + + except Exception as err: + + if not _is_noscript(err): + + raise + + self._sha = None + + return await client.eval(self.body, len(keys), *keys, *args) + + + + + +class RedisLeaseStore(LeaseStore): + + """Lease slots in Redis: one hash per slot, atomic via single-key Lua. + + + + ``client`` is a connected ``redis.asyncio.Redis``. Balances are stored as + + strings so fractional credit amounts survive the round trip. + + """ + + + + def __init__( + + self, + + client: Any, + + *, + + key_prefix: str = DEFAULT_KEY_PREFIX, + + default_lease_duration: float = DEFAULT_LEASE_DURATION, + + clock: Clock = time.time, + + ) -> None: + + self._client = client + + self._key_prefix = key_prefix + + # Only reached when a direct caller extends without an expiry; the + + # lease manager always passes one. + + self._default_lease_duration = default_lease_duration + + self._clock = clock + + self._replace = LuaScript(REPLACE_SCRIPT) + + self._try_reserve = LuaScript(TRY_RESERVE_SCRIPT) + + self._refund = LuaScript(REFUND_SCRIPT) + + self._extend = LuaScript(EXTEND_SCRIPT) + + + + def hash_key(self, company_id: str, credit_type_id: str) -> str: + + """Public so the reservation store can target the same lease hash.""" + + return f"{self._key_prefix}{LEASE_KEY_NAMESPACE}{lease_key(company_id, credit_type_id)}" + + + + async def get(self, company_id: str, credit_type_id: str) -> Optional[LeaseState]: + + raw = decode_hash(await self._client.hgetall(self.hash_key(company_id, credit_type_id))) + + if not raw.get("leaseId"): + + return None + + return LeaseState( + + lease_id=raw["leaseId"], + + company_id=raw.get("companyId", company_id), + + credit_type_id=raw.get("creditTypeId", credit_type_id), + + granted_amount=float(raw.get("grantedAmount", "0")), + + local_remaining_credits=float(raw.get("localRemainingCredits", "0")), + + expires_at=float(raw.get("expiresAt", "0")) / 1000.0, + + ) + + + + async def replace( + + self, + + *, + + lease_id: str, + + company_id: str, + + credit_type_id: str, + + granted_amount: float, + + expires_at: float, + + ) -> bool: + + result = await self._replace.run( + + self._client, + + [self.hash_key(company_id, credit_type_id)], + + # No client clock here: the script reads `now` from the Redis + + # server via TIME, so every pod agrees on expiry. + + [ + + lease_id, + + format_amount(granted_amount), + + to_epoch_ms(expires_at), + + str(LEASE_TTL_GRACE_MS), + + company_id, + + credit_type_id, + + ], + + ) + + return _to_number(result) == 1 + + + + async def try_reserve(self, company_id: str, credit_type_id: str, credits: float) -> Optional[float]: + + # Reject non-finite/negative debits before they reach the script: the + + # string form of NaN parses back to a Lua nan, slips through the `<` + + # comparison, and would poison the SHARED balance for every pod. + + if not is_finite_non_negative(credits): + + return None + + result = await self._try_reserve.run( + + self._client, + + [self.hash_key(company_id, credit_type_id)], + + [format_amount(credits)], + + ) + + if result is None or result is False: + + return None + + return float(to_str(result)) + + + + async def refund( + + self, + + company_id: str, + + credit_type_id: str, + + credits: float, + + pin_lease_id: Optional[str] = None, + + ) -> None: + + if not is_finite_non_negative(credits) or credits <= 0: + + return + + await self._refund.run( + + self._client, + + [self.hash_key(company_id, credit_type_id)], + + # An empty string disables the lease pin (Lua has no nil ARGV). + + [format_amount(credits), pin_lease_id or ""], + + ) + + + + async def extend( + + self, + + company_id: str, + + credit_type_id: str, + + granted_total: float, + + new_expires_at: Optional[float] = None, + + pin_lease_id: Optional[str] = None, + + ) -> None: + + expiry = new_expires_at if new_expires_at is not None else self._clock() + self._default_lease_duration + + await self._extend.run( + + self._client, + + [self.hash_key(company_id, credit_type_id)], + + # granted_total is the server-authoritative TOTAL; the script + + # computes the delta against the stored total. + + [format_amount(granted_total), to_epoch_ms(expiry), str(LEASE_TTL_GRACE_MS), pin_lease_id or ""], + + ) + + + + async def drop(self, company_id: str, credit_type_id: str) -> None: + + # A plain single-key delete: no secondary index to keep in sync. + + await self._client.delete(self.hash_key(company_id, credit_type_id)) + + + + + +def _is_noscript(err: Exception) -> bool: + + """Did Redis answer that it no longer holds this script? + + + + Matched by exception name as well as message so the check does not depend + + on importing redis, nor on a client's exact wording. + + """ + + return type(err).__name__ == "NoScriptError" or "NOSCRIPT" in str(err).upper() + + + + + +def to_str(value: Any) -> str: + + return value.decode("utf-8") if isinstance(value, bytes) else str(value) + + + + + +def decode_hash(raw: Any) -> Dict[str, str]: + + if not raw: + + return {} + + return {to_str(key): to_str(value) for key, value in raw.items()} + + + + + +def _to_number(value: Any) -> float: + + if value is None or value is False: + + return 0.0 + + if value is True: + + return 1.0 + + return float(to_str(value)) + + + + + +def to_epoch_ms(epoch_seconds: float) -> str: + + """Instants cross the wire as integer milliseconds, as the Node SDK writes them.""" + + return str(int(round(epoch_seconds * 1000))) + + + + + +def format_amount(value: float) -> str: + + """Format a credit amount the way JavaScript would, so shared rows read alike.""" + + if float(value).is_integer(): + + return str(int(value)) + + return repr(float(value)) + diff --git a/src/schematic/leases/redis_reservation_store.py b/src/schematic/leases/redis_reservation_store.py + new file mode 100644 + index 0000000..be3c3e8 + --- /dev/null + +++ b/src/schematic/leases/redis_reservation_store.py + @@ -0,0 +1,278 @@ + +"""Redis-backed reservation table: one hash per hold, two secondary indexes. + + + +Key layout, hash fields (camelCase), millisecond instants, the claim script, + +and the TTL grace window match the Node SDK, so Node and Python pods sharing + +one Redis read each other's holds. + + + +Every mutation is a single-key operation (or single-key Lua), so the store is + +correct on standalone and clustered Redis alike: the unspent-slice refund is + +delegated to the lease store rather than reaching across to the lease hash + +inside a multi-key script. + +""" + + + +from __future__ import annotations + + + +import json + +import time + +from typing import Any, Dict, List, Optional, Tuple + + + +from .lease_store import LeaseStore + +from .redis_lease_store import DEFAULT_KEY_PREFIX, LuaScript, decode_hash, format_amount, to_epoch_ms, to_str + +from .reservation_store import ReservationStore, clamp_consumption + +from .types import Clock, ReservationRecord + + + +RES_KEY_NAMESPACE = "credit-reservation:" + +# Sorted set scoring open reservations by expiry so the sweeper can pop expired + +# entries in O(log n). Members encode the full (company, credit, id) tuple. + +RES_INDEX_KEY = "credit-reservations:byExpiry" + +# Per-(company, credit) index of open holds, one hash of id -> creditsReserved, + +# so reserved_credits reads a tenant's holds with one HGETALL. The hash is also + +# the source of truth for that sum: a field exists exactly while its + +# reservation is open and unrefunded. + +RES_BYCREDIT_NAMESPACE = "credit-reservations:byCredit:" + +# Buffer past expiry before Redis evicts the row, so the sweeper has a window + +# to refund. + +RES_TTL_GRACE_MS = 30_000 + + + +# Page size for the sweeper's ZRANGEBYSCORE. Without a limit, a backlog of + +# expired holds (after a Redis outage or a long pod pause) would come back as + +# one giant reply on every pod's next tick; paging bounds the reply while the + +# per-member ZREM keeps offset 0 advancing through the backlog. + +SWEEP_BATCH_SIZE = 256 + +# Upper bound on pages per tick: keeps one sweep's work bounded and guards + +# against an endless loop if ZREM persistently fails. Anything left over is + +# picked up next tick. + +MAX_SWEEP_BATCHES = 16 + + + +# Atomic claim: read the reservation hash and delete it in one step, returning + +# its fields (or nil if it was already gone). Touches a single key. The atomic + +# read-then-delete is what makes consume exactly-once: of two racing callers (a + +# normal settle and a sweeper, say) only one gets the fields back and proceeds + +# to refund. The refund to the lease hash is a separate single-key op; a crash + +# in the gap leaves the unspent slice held on the lease until the lease itself + +# expires, never double-refunded. + +CLAIM_SCRIPT = """ + +local raw = redis.call('HGETALL', KEYS[1]) + +if #raw == 0 then return nil end + +redis.call('DEL', KEYS[1]) + +return raw + +""" + + + + + +class RedisReservationStore(ReservationStore): + + """Reservation table in Redis, refunding through a lease store it is given. + + + + ``client`` is a connected ``redis.asyncio.Redis``. + + """ + + + + def __init__( + + self, + + client: Any, + + lease_store: LeaseStore, + + *, + + key_prefix: str = DEFAULT_KEY_PREFIX, + + clock: Clock = time.time, + + ) -> None: + + self._client = client + + self._lease_store = lease_store + + self._key_prefix = key_prefix + + self._clock = clock + + self._claim = LuaScript(CLAIM_SCRIPT) + + + + def _hash_key(self, reservation_id: str) -> str: + + return f"{self._key_prefix}{RES_KEY_NAMESPACE}{reservation_id}" + + + + def _index_key(self) -> str: + + return f"{self._key_prefix}{RES_INDEX_KEY}" + + + + def _by_credit_key(self, company_id: str, credit_type_id: str) -> str: + + return f"{self._key_prefix}{RES_BYCREDIT_NAMESPACE}{company_id}:{credit_type_id}" + + + + async def add(self, reservation: ReservationRecord) -> None: + + expires_ms = int(to_epoch_ms(reservation.expires_at)) + + hash_key = self._hash_key(reservation.id) + + # The hash goes out first so the reservation exists before anything + + # references it. These are independent single-key ops rather than one + + # multi-key script: a partial failure at worst leaves an un-indexed + + # reservation that the TTL reaps, never a double-spend. + + await self._client.hset( + + hash_key, + + mapping={ + + "id": reservation.id, + + "leaseId": reservation.lease_id, + + "companyId": reservation.company_id, + + "creditTypeId": reservation.credit_type_id, + + "eventSubtype": reservation.event_subtype, + + "quantityReserved": format_amount(reservation.quantity_reserved), + + "creditsReserved": format_amount(reservation.credits_reserved), + + "consumptionRate": format_amount(reservation.consumption_rate), + + "expiresAt": str(expires_ms), + + "evalCtx": _encode_eval_ctx(reservation), + + }, + + ) + + await self._client.pexpireat(hash_key, expires_ms + RES_TTL_GRACE_MS) + + member = _encode_member(reservation.company_id, reservation.credit_type_id, reservation.id) + + await self._client.zadd(self._index_key(), {member: expires_ms}) + + await self._client.hset( + + self._by_credit_key(reservation.company_id, reservation.credit_type_id), + + reservation.id, + + format_amount(reservation.credits_reserved), + + ) + + + + async def get(self, reservation_id: str) -> Optional[ReservationRecord]: + + raw = decode_hash(await self._client.hgetall(self._hash_key(reservation_id))) + + if not raw.get("id"): + + return None + + return _decode_reservation(raw) + + + + async def consume(self, reservation_id: str, credits_consumed: float) -> Optional[float]: + + claimed = await self._claim.run(self._client, [self._hash_key(reservation_id)], []) + + raw = _decode_flat(claimed) + + if not raw or not raw.get("id"): + + return None + + + + company_id = raw["companyId"] + + credit_type_id = raw["creditTypeId"] + + reserved = float(raw.get("creditsReserved", "0")) + + + + # Index cleanup, single-key ops. The per-tenant hash loses the slice + + # BEFORE the refund below, so the lease (local remaining plus this + + # hash) never transiently double-counts it. + + member = _encode_member(company_id, credit_type_id, reservation_id) + + await _ignore_errors(self._client.zrem(self._index_key(), member)) + + await _ignore_errors(self._client.hdel(self._by_credit_key(company_id, credit_type_id), reservation_id)) + + + + consumed = clamp_consumption(credits_consumed, reserved) + + refund = reserved - consumed + + if refund > 0: + + # The lease store owns the lease hash, which keeps this cross-key + + # write out of a single Lua script. Pinned to the reservation's + + # lease so a hold carved out of an expired lease cannot inflate a + + # successor's balance. + + await self._lease_store.refund(company_id, credit_type_id, refund, raw.get("leaseId")) + + return consumed + + + + async def reserved_credits(self, company_id: str, credit_type_id: str) -> float: + + raw = await _ignore_errors(self._client.hgetall(self._by_credit_key(company_id, credit_type_id))) + + total = 0.0 + + for value in decode_hash(raw).values(): + + try: + + total += float(value) + + except ValueError: + + continue + + return total + + + + async def sweep_expired(self, now: Optional[float] = None) -> int: + + cutoff = int(to_epoch_ms(self._clock() if now is None else now)) + + swept = 0 + + # Page through expired members rather than fetching them all at once. + + # Each processed member is removed below, so re-reading at offset 0 + + # advances through the backlog. + + for _ in range(MAX_SWEEP_BATCHES): + + expired = await self._client.zrangebyscore( + + self._index_key(), 0, cutoff, start=0, num=SWEEP_BATCH_SIZE + + ) + + if not expired: + + break + + for member in expired: + + member_str = to_str(member) + + decoded = _decode_member(member_str) + + if decoded is None: + + # Nothing but `add` writes members, so this is + + # belt-and-braces: drop it rather than let it wedge the + + # sweeper. + + await _ignore_errors(self._client.zrem(self._index_key(), member_str)) + + continue + + company_id, credit_type_id, reservation_id = decoded + + refunded = await self.consume(reservation_id, 0) + + # Always drop the member just read. On the success path + + # `consume` already removed it, so this is idempotent; it also + + # covers the hash-evicted path below. + + await _ignore_errors(self._client.zrem(self._index_key(), member_str)) + + if refunded is not None: + + swept += 1 + + continue + + # No reservation hash: either a racing settle consumed it (and + + # reconciled the byCredit field, making this a no-op) or the + + # hash TTL-evicted before the sweeper reached it, orphaning the + + # field. Reconcile so reserved_credits stops summing an evicted + + # hold. Deliberately no refund: without the hash, exactly-once + + # cannot be arbitrated across racing sweepers, so the slice + + # waits for the lease to expire server-side. + + await _ignore_errors( + + self._client.hdel(self._by_credit_key(company_id, credit_type_id), reservation_id) + + ) + + if len(expired) < SWEEP_BATCH_SIZE: + + break + + return swept + + + + async def count(self) -> int: + + result = await _ignore_errors(self._client.zcard(self._index_key())) + + return int(result or 0) + + + + + +def _encode_member(company_id: str, credit_type_id: str, reservation_id: str) -> str: + + """Expiry-index members carry the whole tuple. + + + + The sweeper needs company and credit to clean the per-tenant hash even + + after the reservation hash has TTL-evicted, at which point the claim + + returns nil and cannot report them; otherwise the orphaned field would + + inflate reserved_credits forever. The delimiter is absent from Schematic + + ids and from the reservation id. + + """ + + return f"{company_id}|{credit_type_id}|{reservation_id}" + + + + + +def _decode_member(member: str) -> Optional[Tuple[str, str, str]]: + + parts = member.split("|") + + if len(parts) != 3: + + return None + + return parts[0], parts[1], parts[2] + + + + + +def _encode_eval_ctx(reservation: ReservationRecord) -> str: + + ctx: Dict[str, Any] = {} + + if reservation.company is not None: + + ctx["company"] = reservation.company + + if reservation.user is not None: + + ctx["user"] = reservation.user + + # Compact, like the Node SDK writes it, so a shared row reads identically. + + return json.dumps(ctx, separators=(",", ":")) + + + + + +def _decode_flat(raw: Any) -> Dict[str, str]: + + """Decode the flat [field, value, ...] reply the claim script returns.""" + + if not raw or not isinstance(raw, (list, tuple)): + + return {} + + items: List[str] = [to_str(item) for item in raw] + + return {items[i]: items[i + 1] for i in range(0, len(items) - 1, 2)} + + + + + +def _decode_reservation(raw: Dict[str, str]) -> ReservationRecord: + + ctx: Dict[str, Any] = {} + + if raw.get("evalCtx"): + + try: + + ctx = json.loads(raw["evalCtx"]) + + except ValueError: + + ctx = {} + + return ReservationRecord( + + id=raw["id"], + + lease_id=raw.get("leaseId", ""), + + company_id=raw.get("companyId", ""), + + credit_type_id=raw.get("creditTypeId", ""), + + event_subtype=raw.get("eventSubtype", ""), + + quantity_reserved=float(raw.get("quantityReserved", "0")), + + credits_reserved=float(raw.get("creditsReserved", "0")), + + consumption_rate=float(raw.get("consumptionRate", "0")), + + expires_at=float(raw.get("expiresAt", "0")) / 1000.0, + + company=ctx.get("company"), + + user=ctx.get("user"), + + ) + + + + + +async def _ignore_errors(awaitable: Any) -> Any: + + """Index bookkeeping is best-effort: a failed cleanup must not abort a settle.""" + + try: + + return await awaitable + + except Exception: + + return None + diff --git a/src/schematic/leases/reservation_store.py b/src/schematic/leases/reservation_store.py + new file mode 100644 + index 0000000..121631d + --- /dev/null + +++ b/src/schematic/leases/reservation_store.py + @@ -0,0 +1,125 @@ + +"""Reservation table: the contract, plus the per-process in-memory backend. + + + +A reservation is a hold carved out of a lease. ``add`` does not debit: the + +debit already landed in ``LeaseStore.try_reserve``, and that ordering is what + +bounds a crash to a leaked hold rather than a double-spend. + +""" + + + +from __future__ import annotations + + + +import abc + +import math + +import time + +from typing import Dict, List, Optional + + + +from .lease_store import LeaseStore + +from .types import Clock, ReservationRecord + + + + + +class ReservationStore(abc.ABC): + + """Backing store for open reservations, shared by both backends.""" + + + + @abc.abstractmethod + + async def add(self, reservation: ReservationRecord) -> None: + + """Register a reservation. Idempotent on id.""" + + + + @abc.abstractmethod + + async def get(self, reservation_id: str) -> Optional[ReservationRecord]: + + """Look up a reservation, or ``None`` once it has been claimed or swept.""" + + + + @abc.abstractmethod + + async def consume(self, reservation_id: str, credits_consumed: float) -> Optional[float]: + + """Claim a reservation exactly once and refund its unspent slice. + + + + The claim is atomic and comes first: a racing settle or sweep finds + + nothing to claim, gets ``None``, and refunds nothing. On a successful + + claim, ``credits_consumed`` is clamped to ``[0, credits_reserved]``, the + + remainder is refunded to the lease (pinned to the reservation's lease), + + and the clamped figure is returned. A crash between the claim and the + + refund loses the refund, never double-refunds. + + """ + + + + @abc.abstractmethod + + async def reserved_credits(self, company_id: str, credit_type_id: str) -> float: + + """Sum of ``credits_reserved`` across the slot's open reservations. + + + + A hold counts exactly while it is in the table, so + + ``local_remaining_credits + reserved_credits`` stays exact between + + operations. + + """ + + + + @abc.abstractmethod + + async def sweep_expired(self, now: Optional[float] = None) -> int: + + """Remove every reservation past its TTL, refunding each full hold. + + + + Refunds are pinned to the originating lease, so a hold carved from a + + lease that has since expired is dropped rather than credited to its + + successor. Returns the number swept. + + """ + + + + @abc.abstractmethod + + async def count(self) -> int: + + """Open reservations across every slot.""" + + + + + +class InMemoryReservationStore(ReservationStore): + + """Per-process reservation table refunding into a per-process lease store.""" + + + + def __init__(self, lease_store: LeaseStore, *, clock: Clock = time.time) -> None: + + self._lease_store = lease_store + + self._clock = clock + + self._reservations: Dict[str, ReservationRecord] = {} + + + + async def add(self, reservation: ReservationRecord) -> None: + + self._reservations[reservation.id] = reservation + + + + async def get(self, reservation_id: str) -> Optional[ReservationRecord]: + + return self._reservations.get(reservation_id) + + + + async def consume(self, reservation_id: str, credits_consumed: float) -> Optional[float]: + + # The claim: a dict pop with no await in it, so of two racing callers + + # exactly one comes away with the record. + + reservation = self._reservations.pop(reservation_id, None) + + if reservation is None: + + return None + + consumed = clamp_consumption(credits_consumed, reservation.credits_reserved) + + refund = reservation.credits_reserved - consumed + + if refund > 0: + + await self._lease_store.refund( + + reservation.company_id, + + reservation.credit_type_id, + + refund, + + reservation.lease_id, + + ) + + return consumed + + + + async def reserved_credits(self, company_id: str, credit_type_id: str) -> float: + + return sum( + + reservation.credits_reserved + + for reservation in self._reservations.values() + + if reservation.company_id == company_id and reservation.credit_type_id == credit_type_id + + ) + + + + async def sweep_expired(self, now: Optional[float] = None) -> int: + + cutoff = self._clock() if now is None else now + + expired: List[str] = [ + + reservation_id + + for reservation_id, reservation in self._reservations.items() + + if reservation.expires_at <= cutoff + + ] + + swept = 0 + + for reservation_id in expired: + + # Route through consume so the sweep claims exactly once too. + + if await self.consume(reservation_id, 0) is not None: + + swept += 1 + + return swept + + + + async def count(self) -> int: + + return len(self._reservations) + + + + + +def clamp_consumption(credits_consumed: float, credits_reserved: float) -> float: + + """Local bookkeeping never debits a lease past the hold it took.""" + + if math.isnan(credits_consumed) or credits_consumed < 0: + + return 0.0 + + return min(credits_consumed, credits_reserved) + diff --git a/src/schematic/leases/track.py b/src/schematic/leases/track.py + new file mode 100644 + index 0000000..76c68ab + --- /dev/null + +++ b/src/schematic/leases/track.py + @@ -0,0 +1,82 @@ + +"""Settling a reservation: consume the hold, then build the billing event. + + + +The event is built from the caller-held handle rather than the store, so the + +usage is still billed when the hold has already been swept. The server is the + +source of truth for real consumption; the local bookkeeping only keeps the + +lease's view honest. + +""" + + + +from __future__ import annotations + + + +import math + +from dataclasses import dataclass + +from typing import TYPE_CHECKING, Optional + + + +from ..types import EventBodyTrack + +from .reservation_store import ReservationStore + + + +if TYPE_CHECKING: + + from ..client import Reservation, TrackWithReservationOptions + + + + + +@dataclass + +class ReservationConsumeResult: + + """What a settle did locally, and what it owes the server.""" + + + + track: EventBodyTrack + + # True when the hold was still open and this call debited the consumed + + # slice and refunded the rest. False when it had already been swept at its + + # TTL, already settled, or the store was unreachable: the lease balance was + + # not touched here, so it reads high until the lease rolls over, and the + + # track event is a recovery emit. + + settled_locally: bool + + + + + +async def consume_reservation_and_build_event( + + reservations: ReservationStore, + + reservation: "Reservation", + + actual_quantity: float, + + options: Optional["TrackWithReservationOptions"] = None, + +) -> ReservationConsumeResult: + + """Settle a hold against its lease and build the track event that bills it.""" + + credits = actual_quantity * reservation.consumption_rate + + consumed = await reservations.consume(reservation.id, credits) + + return ReservationConsumeResult( + + track=build_reservation_track_event(reservation, settled_quantity(actual_quantity), options), + + settled_locally=consumed is not None, + + ) + + + + + +def build_reservation_track_event( + + reservation: "Reservation", + + actual_quantity: int, + + options: Optional["TrackWithReservationOptions"] = None, + +) -> EventBodyTrack: + + """Build the track event that settles a reservation, from the handle alone. + + + + Kept free of store access so the client can still bill the usage when the + + local settle fails against an unreachable store. + + """ + + return EventBodyTrack( + + company=reservation.company, + + event=reservation.event_subtype, + + # A client-mode hold routes the server-side consumption through the + + # lease's sub-ledger, instead of decrementing a grant the acquire + + # already pre-debited. In server mode the hold lives on the server and + + # settles by id; never send both, since the server prefers the lease id + + # and there is no lease behind it. + + lease_id=None if reservation.mode == "server" else reservation.lease_id, + + quantity=actual_quantity, + + reservation_id=reservation.id if reservation.mode == "server" else None, + + traits=options.traits if options is not None else None, + + user=reservation.user, + + ) + + + + + +def settled_quantity(actual_quantity: float) -> int: + + """Cast a settled usage onto the integer a track event records. + + + + The hold can be sized from a fractional usage but the event's quantity is + + an integer, so a partial unit settles as a whole one rather than as none. + + """ + + return int(actual_quantity) if float(actual_quantity).is_integer() else math.ceil(actual_quantity) + diff --git a/src/schematic/leases/types.py b/src/schematic/leases/types.py + new file mode 100644 + index 0000000..4a9d8a8 + --- /dev/null + +++ b/src/schematic/leases/types.py + @@ -0,0 +1,162 @@ + +"""Shared types, defaults, and config resolution for client-mode credit leases. + + + +Durations are seconds (floats), like every other duration on this SDK, and + +instants are epoch seconds (floats) rather than ``datetime`` objects: the + +stores compare them against a clock the conformance runner can drive, and + +Redis stores them as numbers anyway. The wire adapter converts at the + +boundary. + +""" + + + +from __future__ import annotations + + + +import math + +from dataclasses import dataclass, field + +from typing import Any, Callable, Dict, Mapping, Optional + + + +# Reads the current time as epoch seconds. Injected into every store and the + +# lease manager so tests and the conformance runner can drive a virtual clock. + +Clock = Callable[[], float] + + + +# Lease lifetime requested at acquire and extend (expires_at = now + duration). + +DEFAULT_LEASE_DURATION = 300.0 + +# Reservation lifetime, and the sweep deadline. Size it above the longest + +# expected gap between check() and track_with_reservation(): a settle arriving + +# after the TTL still bills the server but no longer re-debits the lease. + +DEFAULT_RESERVATION_TTL = 60.0 + +# Credits requested per acquire, and the minimum extend tranche. + +DEFAULT_LEASE_SIZE = 10_000.0 + +# Remaining/granted ratio at or below which a background extend is kicked off. + +DEFAULT_LOW_WATER_MARK = 0.25 + +# Expired-reservation sweep cadence. + +DEFAULT_SWEEP_INTERVAL = 1.0 + +# How long a prewarm waits for a freshly identified company to surface in the + +# datastream cache before giving up. + +DEFAULT_PREWARM_RESOLVE_TIMEOUT = 5.0 + + + + + +@dataclass + +class LeaseState: + + """The local view of the one lease a ``(company, credit type)`` slot holds.""" + + + + lease_id: str + + company_id: str + + credit_type_id: str + + # Server-authoritative total granted to this lease. Grows on extend. + + granted_amount: float + + # Granted minus outstanding holds and consumption. Starts at the full + + # grant when the lease is installed. + + local_remaining_credits: float + + # Epoch seconds. Past it the lease is dead: the server has refunded the + + # remainder to the company balance, so the local balance is stale. + + expires_at: float + + + + + +@dataclass + +class ReservationRecord: + + """One credit hold carved out of a lease by a check.""" + + + + id: str + + # The lease the hold was carved from. Pins refunds so a hold from an + + # expired lease can never inflate its successor. + + lease_id: str + + company_id: str + + credit_type_id: str + + # Event subtype the settling track event is billed as. + + event_subtype: str + + # Caller-declared usage, in event units. + + quantity_reserved: float + + # quantity_reserved * consumption_rate. + + credits_reserved: float + + consumption_rate: float + + # Epoch seconds. The sweeper refunds the full hold past this instant. + + expires_at: float + + # Evaluation context the hold was issued for, threaded onto the track + + # event so the server attributes usage to the same company and user. + + company: Optional[Dict[str, str]] = None + + user: Optional[Dict[str, str]] = None + + + + + +@dataclass(frozen=True) + +class ResolvedLeaseConfig: + + """Config for a single credit type, after overrides and defaults.""" + + + + lease_duration: float = DEFAULT_LEASE_DURATION + + reservation_ttl: float = DEFAULT_RESERVATION_TTL + + lease_size: float = DEFAULT_LEASE_SIZE + + low_water_mark: float = DEFAULT_LOW_WATER_MARK + + + + + +@dataclass + +class LeaseConfigOverride: + + """Per-credit-type overrides of the four resolvable knobs.""" + + + + lease_duration: Optional[float] = None + + reservation_ttl: Optional[float] = None + + lease_size: Optional[float] = None + + low_water_mark: Optional[float] = None + + + + + +@dataclass + +class LeaseConfig: + + """Client-wide lease knobs, in seconds, plus per-credit-type overrides. + + + + The user-facing configuration dataclass lives on the client; this is the + + plain-keyword form the lease machinery resolves against. + + """ + + + + lease_duration: Optional[float] = None + + reservation_ttl: Optional[float] = None + + lease_size: Optional[float] = None + + low_water_mark: Optional[float] = None + + sweep_interval: Optional[float] = None + + overrides: Mapping[str, LeaseConfigOverride] = field(default_factory=dict) + + + + + +def resolve_lease_config( + + config: Optional[LeaseConfig] = None, + + overrides: Optional[Mapping[str, LeaseConfigOverride]] = None, + + credit_type_id: Optional[str] = None, + +) -> ResolvedLeaseConfig: + + """Resolve the knobs for one credit type: override, then client config, then default. + + + + ``overrides`` wins over ``config.overrides`` so a caller can resolve against + + a one-off override map without rebuilding the config. + + """ + + override: Optional[LeaseConfigOverride] = None + + if credit_type_id is not None: + + table = overrides if overrides is not None else (config.overrides if config else None) + + if table: + + override = table.get(credit_type_id) + + + + def pick(name: str, default: float) -> float: + + if override is not None: + + value = getattr(override, name) + + if value is not None: + + return float(value) + + if config is not None: + + value = getattr(config, name) + + if value is not None: + + return float(value) + + return default + + + + return ResolvedLeaseConfig( + + lease_duration=pick("lease_duration", DEFAULT_LEASE_DURATION), + + # Uncapped: a client-mode TTL only tells the local sweeper when to + + # refund an unsettled hold, and never reaches the server. + + reservation_ttl=pick("reservation_ttl", DEFAULT_RESERVATION_TTL), + + lease_size=pick("lease_size", DEFAULT_LEASE_SIZE), + + low_water_mark=pick("low_water_mark", DEFAULT_LOW_WATER_MARK), + + ) + + + + + +def is_valid_quantity(value: Any) -> bool: + + """Whether a caller-supplied quantity can size a credit hold. + + + + A bool is an int in Python, and NaN and infinity are floats that slip + + through every numeric comparison, so a hold would be sized from any of them + + with nothing rejecting it. A NaN debit is the worst of the three: it + + poisons a possibly shared lease balance into approving every later reserve. + + """ + + if isinstance(value, bool) or not isinstance(value, (int, float)): + + return False + + return math.isfinite(value) and value >= 0 + diff --git a/tests/conformance/test_vectors.py b/tests/conformance/test_vectors.py + new file mode 100644 + index 0000000..3c3693e + --- /dev/null + +++ b/tests/conformance/test_vectors.py + @@ -0,0 +1,459 @@ + +"""Runs the language-agnostic conformance vectors against this SDK. + + + +The vectors and the semantics they pin live in ``conformance/`` at the repo + +root, copied verbatim from schematic-node (the reference implementation). This + +runner is the only language-specific piece; every port reimplements it and must + +pass the same vectors, on every store backend it ships. + + + +The flow-level ops (``check`` / ``track``) drive the real orchestration + +against a scripted rules engine and a scripted DataStream, so what they pin is + +what the SDK does around the engine, not the engine itself. + +""" + + + +from __future__ import annotations + + + +import json + +import logging + +from pathlib import Path + +from typing import Any, Callable, Dict, List, Optional + +from unittest import mock + + + +import pytest + +from lease_support import ( + + CrashingRefundLeaseStore, + + ScriptedDataStream, + + ScriptedEngine, + + ScriptedWireClient, + + VirtualClock, + + make_fake_redis, + +) + + + +from schematic.client import CheckOptions, Reservation + +from schematic.leases import ( + + CreditCheckDeps, + + InMemoryLeaseStore, + + InMemoryReservationStore, + + LeaseConfig, + + LeaseManager, + + LeaseState, + + LeaseStore, + + RedisLeaseStore, + + RedisReservationStore, + + ReservationRecord, + + ReservationStore, + + check_with_lease, + + consume_reservation_and_build_event, + +) + + + +VECTORS_DIR = Path(__file__).resolve().parents[2] / "conformance" / "vectors" + +BACKENDS = ("in_memory", "redis") + +# What the vectors write for the balance the fail-open evaluation substitutes: + +# the largest integer a JSON number carries exactly. + +MAX_SAFE_INTEGER = 2**53 - 1 + + + + + +def _load_cases() -> List[Any]: + + cases: List[Any] = [] + + for path in sorted(VECTORS_DIR.glob("*.json")): + + document = json.loads(path.read_text()) + + for vector in document["vectors"]: + + for backend in BACKENDS: + + allowed = vector.get("backends") + + if allowed and backend not in allowed: + + continue + + cases.append( + + pytest.param( + + backend, + + vector, + + id=f"{backend}-{document['category']}-{vector['name']}", + + ) + + ) + + return cases + + + + + +class Harness: + + """One vector's stores, manager, clock, and reservation handles.""" + + + + def __init__(self, backend: str, config: Dict[str, Any]) -> None: + + self.clock = VirtualClock() + + self.handles: Dict[str, Reservation] = {} + + self.wire = ScriptedWireClient() + + self.leases: LeaseStore + + self.reservations: ReservationStore + + if backend == "in_memory": + + self.leases = InMemoryLeaseStore(clock=self.clock) + + self.crash = CrashingRefundLeaseStore(self.leases) + + self.reservations = InMemoryReservationStore(self.crash, clock=self.clock) + + else: + + client = make_fake_redis() + + self.leases = RedisLeaseStore(client, clock=self.clock) + + self.crash = CrashingRefundLeaseStore(self.leases) + + self.reservations = RedisReservationStore(client, self.crash, clock=self.clock) + + self.manager = LeaseManager( + + self.wire, + + self.leases, + + reservation_store=self.reservations, + + config=LeaseConfig( + + lease_duration=_seconds(config.get("lease_duration_ms")), + + reservation_ttl=_seconds(config.get("reservation_ttl_ms")), + + lease_size=config.get("lease_size"), + + low_water_mark=config.get("low_water_mark"), + + ), + + clock=self.clock, + + ) + + + + def at_ms(self, offset_ms: float) -> float: + + return self.clock.at_ms(offset_ms) + + + + def reservation_id(self, op: Dict[str, Any]) -> str: + + if "handle" in op: + + reservation = self.handles.get(op["handle"]) + + if reservation is None: + + raise AssertionError(f"unknown reservation handle: {op['handle']}") + + return reservation.id + + if "id" not in op: + + raise AssertionError(f"op {op['op']} needs an id or handle") + + return str(op["id"]) + + + + async def drain(self) -> None: + + """Let the manager's fire-and-forget work (a redundant release) finish.""" + + await self.manager._drain_background() + + + + + +@pytest.mark.parametrize("backend,vector", _load_cases()) + +async def test_vector(backend: str, vector: Dict[str, Any]) -> None: + + harness = Harness(backend, (vector.get("given") or {}).get("config") or {}) + + # The Redis backend decides expiry against the store's own clock (TIME), + + # so the virtual clock has to be the process clock too, not just the one + + # the stores read. + + with mock.patch("time.time", harness.clock): + + for lease in (vector.get("given") or {}).get("leases") or []: + + written = await harness.leases.replace( + + lease_id=lease["lease_id"], + + company_id=lease["company_id"], + + credit_type_id=lease["credit_type_id"], + + granted_amount=lease["granted_amount"], + + expires_at=harness.at_ms(lease["expires_at_ms"]), + + ) + + assert written is True + + for op in vector["operations"]: + + handler = _HANDLERS.get(op["op"]) + + if handler is None: + + raise AssertionError(f"unknown conformance op: {op['op']}") + + await handler(harness, op, op.get("expect") or {}) + + harness.manager.stop() + + + + + +async def _op_advance_clock(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + + h.clock.advance_ms(op.get("ms") or 0) + + + + + +async def _op_replace_lease(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + + written = await h.leases.replace( + + lease_id=op["lease_id"], + + company_id=op["company_id"], + + credit_type_id=op["credit_type_id"], + + granted_amount=op["granted_amount"], + + expires_at=h.at_ms(op["expires_at_ms"]), + + ) + + if "written" in expect: + + assert written is expect["written"] + + + + + +async def _op_drop_lease(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + + await h.leases.drop(op["company_id"], op["credit_type_id"]) + + + + + +async def _op_try_reserve(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + + balance = await h.leases.try_reserve(op["company_id"], op["credit_type_id"], op["credits"]) + + if "balance" in expect: + + _assert_number(balance, expect["balance"]) + + + + + +async def _op_refund_lease(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + + await h.leases.refund(op["company_id"], op["credit_type_id"], op["credits"], op.get("pin_lease_id")) + + + + + +async def _op_extend_lease(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + + expires_at = h.at_ms(op["expires_at_ms"]) if "expires_at_ms" in op else None + + await h.leases.extend( + + op["company_id"], + + op["credit_type_id"], + + op["granted_total"], + + expires_at, + + op.get("pin_lease_id"), + + ) + + + + + +async def _op_get_lease(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + + entry = await h.leases.get(op["company_id"], op["credit_type_id"]) + + if "exists" in expect: + + assert (entry is not None) is expect["exists"] + + if "lease_id" in expect: + + assert (entry.lease_id if entry else None) == expect["lease_id"] + + if "granted_amount" in expect: + + assert entry is not None and entry.granted_amount == expect["granted_amount"] + + if "local_remaining_credits" in expect: + + assert entry is not None and entry.local_remaining_credits == expect["local_remaining_credits"] + + + + + +async def _op_add_reservation(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + + await h.reservations.add( + + ReservationRecord( + + id=op["id"], + + lease_id=op["lease_id"], + + company_id=op["company_id"], + + credit_type_id=op["credit_type_id"], + + event_subtype=op["event_subtype"], + + quantity_reserved=op["quantity_reserved"], + + credits_reserved=op["credits_reserved"], + + consumption_rate=op["consumption_rate"], + + expires_at=h.at_ms(op["expires_at_ms"]), + + company={"id": op["company_id"]}, + + ) + + ) + + + + + +async def _op_consume_reservation(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + + reservation_id = h.reservation_id(op) + + if op.get("crash_before_refund"): + + h.crash.arm() + + with pytest.raises(RuntimeError, match="simulated crash before refund"): + + await h.reservations.consume(reservation_id, op["credits"]) + + assert expect.get("throws") is True + + return + + consumed = await h.reservations.consume(reservation_id, op["credits"]) + + if "consumed" in expect: + + _assert_number(consumed, expect["consumed"]) + + + + + +async def _op_get_reservation(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + + reservation = await h.reservations.get(h.reservation_id(op)) + + if "exists" in expect: + + assert (reservation is not None) is expect["exists"] + + + + + +async def _op_reserved_credits(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + + total = await h.reservations.reserved_credits(op["company_id"], op["credit_type_id"]) + + assert total == expect["total"] + + + + + +async def _op_reservation_count(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + + assert await h.reservations.count() == expect["count"] + + + + + +async def _op_sweep_expired(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + + swept = await h.reservations.sweep_expired() + + if "swept" in expect: + + assert swept == expect["swept"] + + + + + +async def _op_acquire_if_needed(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + + if op.get("server"): + + h.wire.acquire_responses.append(_server_script(h, op["server"])) + + install = op.get("install_during_wire") + + if install: + + + + async def install_lease() -> None: + + await h.leases.replace( + + lease_id=install["lease_id"], + + company_id=install["company_id"], + + credit_type_id=install["credit_type_id"], + + granted_amount=install["granted_amount"], + + expires_at=h.at_ms(install["expires_at_ms"]), + + ) + + + + h.wire.during_acquire = install_lease + + entry = await h.manager.acquire_if_needed(op["company_id"], op["credit_type_id"]) + + await h.drain() + + if "lease_id" in expect: + + assert (entry.lease_id if entry else None) == expect["lease_id"] + + if "wire_acquires" in expect: + + assert len(h.wire.acquire_calls) == expect["wire_acquires"] + + if "last_acquire_requested_amount" in expect: + + assert h.wire.acquire_calls[-1]["requested_amount"] == expect["last_acquire_requested_amount"] + + if "released_lease_ids" in expect: + + assert h.wire.release_calls == expect["released_lease_ids"] + + + + + +async def _op_maybe_extend(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + + if op.get("server"): + + h.wire.extend_responses.append(_server_script(h, op["server"])) + + await h.manager.maybe_extend(op["company_id"], op["credit_type_id"], op.get("required_credits")) + + await h.drain() + + if "wire_extends" in expect: + + assert len(h.wire.extend_calls) == expect["wire_extends"] + + if "last_extend_additional_amount" in expect: + + assert h.wire.extend_calls[-1]["additional_amount"] == expect["last_extend_additional_amount"] + + if "last_extend_lease_id" in expect: + + assert h.wire.extend_calls[-1]["lease_id"] == expect["last_extend_lease_id"] + + + + + +async def _op_release_all_local_leases(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + + await h.manager.release_all_local_leases() + + if "released_lease_ids" in expect: + + assert h.wire.release_calls == expect["released_lease_ids"] + + if "remaining_slots" in expect: + + remaining: Optional[List[LeaseState]] = h.leases.list_leases() + + assert len(remaining or []) == expect["remaining_slots"] + + + + + +async def _op_check(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + + flag_key = op.get("flag_key") or "flag" + + company = op.get("company") or {"id": "co_1"} + + engine = ScriptedEngine(op.get("engine") or [], flag_key) + + datastream = ScriptedDataStream(engine, flag_key, company) + + for call, script in (op.get("server") or {}).items(): + + queue = h.wire.acquire_responses if call == "acquire" else h.wire.extend_responses + + queue.append(_server_script(h, script)) + + + + fallback_called = False + + + + async def fallback() -> Any: + + nonlocal fallback_called + + fallback_called = True + + from schematic.client import CheckResult + + + + return CheckResult(allowed=True, value=True, reason="fallback", flag_key=flag_key) + + + + async def enqueue_flag_check(body: Any) -> None: + + """The vectors pin the lease flow, not analytics, so drop the event.""" + + + + deps = CreditCheckDeps( + + datastream=datastream, + + lease_store=h.leases, + + reservations=h.reservations, + + manager=h.manager, + + logger=logging.getLogger("conformance"), + + enqueue_flag_check=enqueue_flag_check, + + clock=h.clock, + + ) + + options = CheckOptions( + + usage=op.get("usage"), + + event_subtype=op.get("event_subtype"), + + on_acquire_failure=op.get("on_acquire_failure") or "fail-closed", + + ) + + result = await check_with_lease(deps, flag_key, {"id": company["id"]}, None, options, fallback) + + await h.drain() + + + + if "allowed" in expect: + + assert result.allowed is expect["allowed"] + + if "reason" in expect: + + assert result.reason == expect["reason"] + + if "err" in expect: + + assert result.error == expect["err"] + + if "has_reservation" in expect: + + assert (result.reservation is not None) is expect["has_reservation"] + + if "fallback_called" in expect: + + assert fallback_called is expect["fallback_called"] + + if expect.get("reservation"): + + reservation = result.reservation + + assert reservation is not None + + for field, attribute in ( + + ("lease_id", "lease_id"), + + ("credit_type_id", "credit_type_id"), + + ("event_subtype", "event_subtype"), + + ("quantity_reserved", "quantity_reserved"), + + ("credits_reserved", "credits_reserved"), + + ("consumption_rate", "consumption_rate"), + + ): + + if field in expect["reservation"]: + + assert getattr(reservation, attribute) == expect["reservation"][field] + + if "engine_calls" in expect: + + _assert_engine_calls(engine.calls, expect["engine_calls"], _credit_id(op)) + + if "wire_extends" in expect: + + assert len(h.wire.extend_calls) == expect["wire_extends"] + + if "last_extend_additional_amount" in expect: + + assert h.wire.extend_calls[-1]["additional_amount"] == expect["last_extend_additional_amount"] + + if op.get("save_reservation_as") and result.reservation is not None: + + h.handles[op["save_reservation_as"]] = result.reservation + + + + + +async def _op_track(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + + reservation = h.handles.get(op["handle"]) + + if reservation is None: + + raise AssertionError(f"unknown reservation handle: {op['handle']}") + + outcome = await consume_reservation_and_build_event(h.reservations, reservation, op["actual_quantity"]) + + if "settled_locally" in expect: + + assert outcome.settled_locally is expect["settled_locally"] + + track = expect.get("track") or {} + + if "event" in track: + + assert outcome.track.event == track["event"] + + if "quantity" in track: + + assert outcome.track.quantity == track["quantity"] + + if "lease_id" in track: + + assert outcome.track.lease_id == track["lease_id"] + + + + + +def _credit_id(op: Dict[str, Any]) -> Optional[str]: + + """The credit the vector's engine_calls expectations are keyed on.""" + + for scripted in op.get("engine") or []: + + credit_id = (scripted.get("entitlement") or {}).get("credit_id") + + if credit_id: + + return str(credit_id) + + balances = (op.get("company") or {}).get("credit_balances") or {} + + return next(iter(balances), None) + + + + + +def _assert_engine_calls( + + recorded: List[Dict[str, Any]], expected: List[Dict[str, Any]], credit_id: Optional[str], + +) -> None: + + assert len(recorded) == len(expected) + + for got, want in zip(recorded, expected): + + if "credit_balance" in want: + + balance = want["credit_balance"] + + assert credit_id is not None + + assert got["credit_balances"].get(credit_id) == ( + + MAX_SAFE_INTEGER if balance == "max_safe_integer" else balance + + ) + + if "credit_cost" in want: + + assert (got["credit_cost"] or {}).get(credit_id) == want["credit_cost"] + + if "event_usage" in want: + + assert got["event_usage"] == want["event_usage"] + + if "usage" in want: + + assert got["usage"] == want["usage"] + + + + + +_Handler = Callable[[Harness, Dict[str, Any], Dict[str, Any]], Any] + + + +_HANDLERS: Dict[str, _Handler] = { + + "advance_clock": _op_advance_clock, + + "replace_lease": _op_replace_lease, + + "drop_lease": _op_drop_lease, + + "try_reserve": _op_try_reserve, + + "refund_lease": _op_refund_lease, + + "extend_lease": _op_extend_lease, + + "get_lease": _op_get_lease, + + "add_reservation": _op_add_reservation, + + "consume_reservation": _op_consume_reservation, + + "get_reservation": _op_get_reservation, + + "reserved_credits": _op_reserved_credits, + + "reservation_count": _op_reservation_count, + + "sweep_expired": _op_sweep_expired, + + "acquire_if_needed": _op_acquire_if_needed, + + "maybe_extend": _op_maybe_extend, + + "release_all_local_leases": _op_release_all_local_leases, + + "check": _op_check, + + "track": _op_track, + +} + + + + + +def _server_script(h: Harness, server: Dict[str, Any]) -> Dict[str, Any]: + + """Turn a vector's server script into one the wire stand-in can serve.""" + + if server.get("error") is not None: + + return {"error": server["error"]} + + lease: Dict[str, Any] = dict(server["lease"]) + + lease["expires_at"] = h.at_ms(lease["expires_at_ms"]) + + return {"lease": lease} + + + + + +def _assert_number(actual: Optional[float], expected: Optional[float]) -> None: + + """``None`` is the refused result, so it never compares equal to a figure.""" + + if expected is None: + + assert actual is None + + return + + assert actual is not None and actual == expected + + + + + +def _seconds(milliseconds: Optional[float]) -> Optional[float]: + + return None if milliseconds is None else milliseconds / 1000.0 + diff --git a/tests/lease_support.py b/tests/lease_support.py + new file mode 100644 + index 0000000..cb5a49f + --- /dev/null + +++ b/tests/lease_support.py + @@ -0,0 +1,366 @@ + +"""Harness shared by the lease unit tests and the conformance runner. + + + +Everything here is test-only: the virtual clock, the fakeredis client the + +verbatim Lua scripts can run against, the crash seam the bounded-leak tests + +need, and scriptable stand-ins for the lease wire API, the rules engine, and + +DataStream. + +""" + + + +from __future__ import annotations + + + +import datetime as dt + +from typing import Any, Awaitable, Dict, List, Optional, cast + + + +import fakeredis.aioredis + + + +from schematic.leases import LeaseGrant, LeaseState, ReservationRecord + +from schematic.leases.lease_store import LeaseStore + +from schematic.types import ( + + RulesengineCheckFlagResult, + + RulesengineCompany, + + RulesengineFeatureEntitlement, + + RulesengineFlag, + +) + + + +# The fixed virtual instant every vector and test starts from. + +T0 = dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc).timestamp() + + + + + +class VirtualClock: + + """A clock only ``advance`` moves, so no test depends on wall time.""" + + + + def __init__(self, now: float = T0) -> None: + + self._now = now + + + + def __call__(self) -> float: + + return self._now + + + + def advance_ms(self, milliseconds: float) -> None: + + self._now += milliseconds / 1000.0 + + + + def at_ms(self, offset_ms: float) -> float: + + """An absolute position on the virtual timeline, as the vectors express it.""" + + return T0 + offset_ms / 1000.0 + + + + + +class LuaCompatFakeRedis(fakeredis.aioredis.FakeRedis): + + """fakeredis with the one Lua builtin its runtime lacks papered over. + + + + ``redis.replicate_commands()`` is what lets a real Redis 5/6 read TIME in a + + writing script; fakeredis's Lua runtime does not define it. Stripping the + + call here (rather than dropping it from the scripts) keeps the shipped Lua + + byte-identical to the Node SDK's, which is what lets both fleets share one + + Redis. The scripts are sent through SCRIPT LOAD as well as EVAL, so both + + are rewritten and the server-assigned digest stays consistent. + + """ + + + + @staticmethod + + def _strip(script: str) -> str: + + return script.replace("redis.replicate_commands()\n", "") + + + + async def script_load(self, script: str) -> Any: # type: ignore[override] + + return await cast(Awaitable[Any], super().script_load(self._strip(script))) + + + + async def eval(self, script: str, numkeys: int, *keys_and_args: Any) -> Any: # type: ignore[override] + + return await cast(Awaitable[Any], super().eval(self._strip(script), numkeys, *keys_and_args)) + + + + + +def make_fake_redis() -> LuaCompatFakeRedis: + + return LuaCompatFakeRedis(decode_responses=True) + + + + + +class CrashingRefundLeaseStore(LeaseStore): + + """Lease store whose ``refund`` raises once while armed. + + + + The reservation store refunds through the store it is handed, so wrapping + + that one reproduces a process death between the claim and the refund while + + the test body keeps reading the real store. + + """ + + + + def __init__(self, target: LeaseStore) -> None: + + self._target = target + + self._armed = False + + + + def arm(self) -> None: + + self._armed = True + + + + async def get(self, company_id: str, credit_type_id: str) -> Optional[LeaseState]: + + return await self._target.get(company_id, credit_type_id) + + + + async def replace( + + self, + + *, + + lease_id: str, + + company_id: str, + + credit_type_id: str, + + granted_amount: float, + + expires_at: float, + + ) -> bool: + + return await self._target.replace( + + lease_id=lease_id, + + company_id=company_id, + + credit_type_id=credit_type_id, + + granted_amount=granted_amount, + + expires_at=expires_at, + + ) + + + + async def try_reserve(self, company_id: str, credit_type_id: str, credits: float) -> Optional[float]: + + return await self._target.try_reserve(company_id, credit_type_id, credits) + + + + async def refund( + + self, + + company_id: str, + + credit_type_id: str, + + credits: float, + + pin_lease_id: Optional[str] = None, + + ) -> None: + + if self._armed: + + self._armed = False + + raise RuntimeError("simulated crash before refund") + + await self._target.refund(company_id, credit_type_id, credits, pin_lease_id) + + + + async def extend( + + self, + + company_id: str, + + credit_type_id: str, + + granted_total: float, + + new_expires_at: Optional[float] = None, + + pin_lease_id: Optional[str] = None, + + ) -> None: + + await self._target.extend(company_id, credit_type_id, granted_total, new_expires_at, pin_lease_id) + + + + async def drop(self, company_id: str, credit_type_id: str) -> None: + + await self._target.drop(company_id, credit_type_id) + + + + def list_leases(self) -> Optional[List[LeaseState]]: + + return self._target.list_leases() + + + + + +class ScriptedWireClient: + + """Stands in for the lease API: queued responses in, recorded calls out.""" + + + + def __init__(self) -> None: + + self.acquire_responses: List[Dict[str, Any]] = [] + + self.extend_responses: List[Dict[str, Any]] = [] + + self.acquire_calls: List[Dict[str, Any]] = [] + + self.extend_calls: List[Dict[str, Any]] = [] + + self.release_calls: List[str] = [] + + # Runs while an acquire is in flight, for emulating a sibling pod + + # winning the race. + + self.during_acquire: Optional[Any] = None + + + + async def acquire( + + self, + + company_id: str, + + credit_type_id: str, + + requested_amount: float, + + expires_at: float, + + timeout: Optional[float] = None, + + ) -> LeaseGrant: + + self.acquire_calls.append( + + { + + "company_id": company_id, + + "credit_type_id": credit_type_id, + + "requested_amount": requested_amount, + + "expires_at": expires_at, + + "timeout": timeout, + + } + + ) + + during = self.during_acquire + + if during is not None: + + self.during_acquire = None + + await during() + + scripted = self.acquire_responses.pop(0) if self.acquire_responses else None + + lease = _scripted_lease(scripted, "unscripted acquire wire call") + + return LeaseGrant( + + lease_id=lease.get("lease_id", "lse_unnamed"), + + company_id=company_id, + + credit_type_id=credit_type_id, + + granted_amount=float(lease.get("granted_amount", 0)), + + expires_at=lease["expires_at"], + + ) + + + + async def extend( + + self, + + lease_id: str, + + additional_amount: float, + + expires_at: float, + + timeout: Optional[float] = None, + + ) -> LeaseGrant: + + self.extend_calls.append( + + { + + "lease_id": lease_id, + + "additional_amount": additional_amount, + + "expires_at": expires_at, + + "timeout": timeout, + + } + + ) + + scripted = self.extend_responses.pop(0) if self.extend_responses else None + + lease = _scripted_lease(scripted, "unscripted extend wire call") + + return LeaseGrant( + + lease_id=lease_id, + + company_id=lease.get("company_id", "co_wire"), + + credit_type_id=lease.get("credit_type_id", "ct_wire"), + + granted_amount=float(lease.get("granted_total", lease.get("granted_amount", 0))), + + expires_at=lease["expires_at"], + + ) + + + + async def release(self, lease_id: str) -> None: + + self.release_calls.append(lease_id) + + + + + +def _scripted_lease(scripted: Optional[Dict[str, Any]], missing: str) -> Dict[str, Any]: + + if scripted is None: + + raise RuntimeError(missing) + + if scripted.get("error") is not None or not scripted.get("lease"): + + raise RuntimeError(str(scripted.get("error") or missing)) + + lease: Dict[str, Any] = dict(scripted["lease"]) + + return lease + + + + + +def make_reservation(**overrides: Any) -> ReservationRecord: + + fields: Dict[str, Any] = { + + "id": "res_1", + + "lease_id": "lse_1", + + "company_id": "co_1", + + "credit_type_id": "ct_1", + + "event_subtype": "inference_tokens", + + "quantity_reserved": 10, + + "credits_reserved": 100, + + "consumption_rate": 10, + + "expires_at": T0 + 60, + + "company": {"id": "co_1"}, + + } + + fields.update(overrides) + + return ReservationRecord(**fields) + + + + + +class ScriptedEngine: + + """Stands in for the WASM rules engine: queued verdicts in, calls out. + + + + The vectors treat the engine as an oracle. What they pin is the + + orchestration around it, so every call records the credit balance the SDK + + substituted and the preflight it threaded. + + """ + + + + def __init__(self, results: List[Dict[str, Any]], flag_key: str = "flag") -> None: + + self._results = list(results) + + self._flag_key = flag_key + + self.calls: List[Dict[str, Any]] = [] + + + + def __call__( + + self, + + flag: Any, + + company: Any, + + user: Any, + + options: Any = None, + + ) -> RulesengineCheckFlagResult: + + event_usage = getattr(options, "event_usage", None) + + self.calls.append( + + { + + "credit_balances": dict(getattr(company, "credit_balances", None) or {}), + + "credit_cost": getattr(options, "credit_cost", None), + + "event_usage": ( + + {"event_subtype": event_usage.event_subtype, "quantity": event_usage.quantity} + + if event_usage is not None + + else None + + ), + + "usage": getattr(options, "usage", None), + + } + + ) + + if not self._results: + + raise RuntimeError(f"unscripted engine call for flag {self._flag_key}") + + scripted = self._results.pop(0) + + entitlement = scripted.get("entitlement") + + return RulesengineCheckFlagResult( + + value=scripted["value"], + + reason=scripted.get("reason") or "", + + flag_key=self._flag_key, + + flag_id="flag_1", + + entitlement=_scripted_entitlement(entitlement, self._flag_key) if entitlement else None, + + ) + + + + + +class ScriptedDataStream: + + """The slice of ``DataStreamClient`` a lease-bearing check touches. + + + + The keyword arguments stage the misses the check flow has to survive: a + + flag that is not cached, a company or user the socket cannot resolve. + + """ + + + + def __init__( + + self, + + engine: Any, + + flag_key: str, + + company: Dict[str, Any], + + *, + + user: Optional[Any] = None, + + missing_flag: bool = False, + + company_error: Optional[Exception] = None, + + user_error: Optional[Exception] = None, + + company_cached: bool = False, + + ) -> None: + + self._engine = engine + + self._flag_key = flag_key + + self._company = make_company(company["id"], company.get("credit_balances") or {}) + + self._user = user + + self._missing_flag = missing_flag + + self._company_error = company_error + + self._user_error = user_error + + self._company_cached = company_cached + + + + async def get_flag(self, flag_key: str) -> Optional[RulesengineFlag]: + + if self._missing_flag: + + return None + + return RulesengineFlag( + + id="flag_1", + + key=self._flag_key, + + account_id="acc_1", + + environment_id="env_1", + + default_value=False, + + rules=[], + + ) + + + + async def get_company(self, keys: Dict[str, str]) -> RulesengineCompany: + + if self._company_error is not None: + + raise self._company_error + + return self._company + + + + async def get_cached_company(self, keys: Dict[str, str]) -> Optional[RulesengineCompany]: + + return self._company if self._company_cached else None + + + + async def get_user(self, keys: Dict[str, str]) -> Any: + + if self._user_error is not None: + + raise self._user_error + + return self._user + + + + def evaluate_flag(self, flag: Any, company: Any, user: Any, options: Any = None) -> RulesengineCheckFlagResult: + + return self._engine(flag, company, user, options) + + + + + +def make_company(company_id: str, credit_balances: Dict[str, float]) -> RulesengineCompany: + + return RulesengineCompany( + + id=company_id, + + account_id="acc_1", + + environment_id="env_1", + + keys={"id": company_id}, + + traits=[], + + metrics=[], + + rules=[], + + entitlements=[], + + billing_product_ids=[], + + credit_balances=dict(credit_balances), + + plan_ids=[], + + plan_version_ids=[], + + ) + + + + + +def _scripted_entitlement(spec: Dict[str, Any], flag_key: str) -> RulesengineFeatureEntitlement: + + return RulesengineFeatureEntitlement( + + feature_id=spec.get("feature_id") or "feat_1", + + feature_key=spec.get("feature_key") or flag_key, + + value_type=spec["value_type"], + + credit_id=spec.get("credit_id"), + + consumption_rate=spec.get("consumption_rate"), + + event_subtype=spec.get("event_subtype"), + + ) + diff --git a/tests/leases/__init__.py b/tests/leases/__init__.py + new file mode 100644 + index 0000000..e69de29 + diff --git a/tests/leases/conftest.py b/tests/leases/conftest.py + new file mode 100644 + index 0000000..0921303 + --- /dev/null + +++ b/tests/leases/conftest.py + @@ -0,0 +1,29 @@ + +from __future__ import annotations + + + +from typing import Iterator + +from unittest import mock + + + +import pytest + +from lease_support import VirtualClock, make_fake_redis + + + + + +@pytest.fixture + +def clock() -> VirtualClock: + + return VirtualClock() + + + + + +@pytest.fixture + +def frozen_clock(clock: VirtualClock) -> Iterator[VirtualClock]: + + """A virtual clock that is also the process clock. + + + + The Redis stores decide expiry against the store's own clock (Redis TIME), + + which fakeredis reads from ``time.time``, so a test that moves the virtual + + clock has to move that one too. + + """ + + with mock.patch("time.time", clock): + + yield clock + + + + + +@pytest.fixture + +def redis_client(frozen_clock: VirtualClock) -> object: + + return make_fake_redis() + diff --git a/tests/leases/test_check_and_track.py b/tests/leases/test_check_and_track.py + new file mode 100644 + index 0000000..e7a7b55 + --- /dev/null + +++ b/tests/leases/test_check_and_track.py + @@ -0,0 +1,675 @@ + +"""The client-mode check and settle flow against scripted stores and engine. + + + +Ports schematic-node's check-and-track suite. The engine is scripted rather + +than real (``test_wasm_credit_gate`` drives the real one), so what these pin is + +the orchestration: which balance the engine is handed, when a hold is taken, + +and what happens to it when something downstream says no. + +""" + + + +from __future__ import annotations + + + +import asyncio + +import logging + +from dataclasses import dataclass, field + +from typing import Any, Dict, List, Optional + + + +import pytest + +from lease_support import ScriptedDataStream, ScriptedWireClient, VirtualClock + + + +from schematic.client import CheckOptions, CheckResult, Reservation + +from schematic.leases import ( + + CreditCheckDeps, + + InMemoryLeaseStore, + + InMemoryReservationStore, + + LeaseConfig, + + LeaseManager, + + LeaseStore, + + ReservationRecord, + + check_with_lease, + + consume_reservation_and_build_event, + +) + +from schematic.leases.check import FAIL_OPEN_BALANCE + +from schematic.types import ( + + EventBodyFlagCheck, + + RulesengineCheckFlagResult, + + RulesengineFeatureEntitlement, + + RulesengineUser, + +) + + + +FLAG_KEY = "inference" + +CREDIT_ID = "bilcr_inference" + +EVENT_SUBTYPE = "inference_tokens" + +LEASE_SIZE = 1000.0 + +LEASE_DURATION = 300.0 + +COMPANY = {"id": "co_1"} + + + +CREDIT_ENTITLEMENT = RulesengineFeatureEntitlement( + + feature_id="feat", + + feature_key=FLAG_KEY, + + value_type="credit", + + credit_id=CREDIT_ID, + + consumption_rate=10, + + event_subtype=EVENT_SUBTYPE, + +) + + + + + +def _verdict( + + value: bool, reason: str, entitlement: Optional[RulesengineFeatureEntitlement] = None, + +) -> RulesengineCheckFlagResult: + + return RulesengineCheckFlagResult( + + value=value, reason=reason, flag_key=FLAG_KEY, flag_id="flag_1", entitlement=entitlement + + ) + + + + + +class FlowEngine: + + """The engine the lease path asks twice: the probe, then the gate. + + + + The gate is the call carrying ``credit_cost``, which is also what puts the + + fail-open re-evaluation on the probe branch, as it is in the reference + + implementation. + + """ + + + + def __init__( + + self, + + *, + + probe: Optional[RulesengineCheckFlagResult] = None, + + gate: Optional[RulesengineCheckFlagResult] = None, + + probe_error: Optional[Exception] = None, + + gate_error: Optional[Exception] = None, + + ) -> None: + + self.probe = probe if probe is not None else _verdict(True, "probe", CREDIT_ENTITLEMENT) + + self.gate = gate if gate is not None else _verdict(True, "matched", CREDIT_ENTITLEMENT) + + self.probe_error = probe_error + + self.gate_error = gate_error + + self.calls: List[Dict[str, Any]] = [] + + + + def __call__(self, flag: Any, company: Any, user: Any, options: Any = None) -> RulesengineCheckFlagResult: + + gating = bool(options is not None and getattr(options, "credit_cost", None)) + + self.calls.append({"company": company, "user": user, "options": options, "gating": gating}) + + if gating: + + if self.gate_error is not None: + + raise self.gate_error + + return self.gate + + if self.probe_error is not None: + + raise self.probe_error + + return self.probe + + + + @property + + def gate_call(self) -> Optional[Dict[str, Any]]: + + return next((call for call in self.calls if call["gating"]), None) + + + + def balance(self, index: int) -> float: + + return float(self.calls[index]["company"].credit_balances[CREDIT_ID]) + + + + + +class _ProbeThenExplodes(FlowEngine): + + """Answers the probe, then fails every evaluation after it.""" + + + + def __call__(self, flag: Any, company: Any, user: Any, options: Any = None) -> RulesengineCheckFlagResult: + + result = super().__call__(flag, company, user, options) + + if len(self.calls) > 1: + + raise RuntimeError("wasm exploded") + + return result + + + + + +class Fallback: + + """The plain flag check the lease path defers to.""" + + + + def __init__(self) -> None: + + self.called = False + + + + async def __call__(self) -> CheckResult: + + self.called = True + + return CheckResult(allowed=True, value=True, reason="fallback", flag_key=FLAG_KEY) + + + + + +class UnreachableLeaseStore(InMemoryLeaseStore): + + """A store that can be read but never debited, as an unreachable Redis is.""" + + + + async def try_reserve(self, company_id: str, credit_type_id: str, credits: float) -> Optional[float]: + + raise RuntimeError("redis down") + + + + + +class RecordedFlagChecks: + + """Stands in for the client's event buffer so the tests can see what the + + lease path reported.""" + + + + def __init__(self) -> None: + + self.events: List[EventBodyFlagCheck] = [] + + self.explode = False + + + + async def __call__(self, body: EventBodyFlagCheck) -> None: + + if self.explode: + + raise RuntimeError("event buffer down") + + self.events.append(body) + + + + + +@dataclass + +class Flow: + + deps: CreditCheckDeps + + engine: FlowEngine + + wire: ScriptedWireClient + + leases: LeaseStore + + reservations: InMemoryReservationStore + + manager: LeaseManager + + flag_checks: RecordedFlagChecks = field(default_factory=RecordedFlagChecks) + + fallback: Fallback = field(default_factory=Fallback) + + + + async def check(self, **option_overrides: Any) -> CheckResult: + + options = CheckOptions(usage=50, event_subtype=EVENT_SUBTYPE) + + for name, value in option_overrides.items(): + + setattr(options, name, value) + + result = await check_with_lease(self.deps, FLAG_KEY, COMPANY, None, options, self.fallback) + + await self.manager._drain_background() + + return result + + + + async def remaining(self) -> Optional[float]: + + entry = await self.leases.get(COMPANY["id"], CREDIT_ID) + + return entry.local_remaining_credits if entry is not None else None + + + + + +def make_flow( + + clock: VirtualClock, + + *, + + engine: Optional[FlowEngine] = None, + + lease_store: Optional[LeaseStore] = None, + + acquire: str = "ok", + + credit_balances: Optional[Dict[str, float]] = None, + + **datastream_kwargs: Any, + +) -> Flow: + + engine = engine or FlowEngine() + + leases = lease_store if lease_store is not None else InMemoryLeaseStore(clock=clock) + + reservations = InMemoryReservationStore(leases, clock=clock) + + wire = ScriptedWireClient() + + if acquire == "ok": + + wire.acquire_responses.append( + + {"lease": {"lease_id": "lse_1", "granted_amount": LEASE_SIZE, "expires_at": clock() + LEASE_DURATION}} + + ) + + elif acquire == "error": + + wire.acquire_responses.append({"error": "lease 503"}) + + manager = LeaseManager( + + wire, + + leases, + + reservation_store=reservations, + + config=LeaseConfig( + + lease_duration=LEASE_DURATION, reservation_ttl=60.0, lease_size=LEASE_SIZE, low_water_mark=0.25 + + ), + + clock=clock, + + ) + + datastream = ScriptedDataStream( + + engine, + + FLAG_KEY, + + {"id": COMPANY["id"], "credit_balances": credit_balances if credit_balances is not None else {CREDIT_ID: 5000}}, + + **datastream_kwargs, + + ) + + flag_checks = RecordedFlagChecks() + + return Flow( + + deps=CreditCheckDeps( + + datastream=datastream, + + lease_store=leases, + + reservations=reservations, + + manager=manager, + + logger=logging.getLogger("lease-flow-test"), + + enqueue_flag_check=flag_checks, + + clock=clock, + + ), + + engine=engine, + + wire=wire, + + leases=leases, + + reservations=reservations, + + manager=manager, + + flag_checks=flag_checks, + + ) + + + + + +class TestCheckWithLease: + + async def test_issues_a_reservation_when_the_engine_allows(self, clock: VirtualClock) -> None: + + flow = make_flow(clock) + + result = await flow.check() + + + + assert result.allowed is True + + assert result.reservation is not None + + assert result.reservation.mode == "client" + + assert result.reservation.lease_id == "lse_1" + + assert result.reservation.quantity_reserved == 50 + + assert result.reservation.credits_reserved == 500 + + assert result.reservation.consumption_rate == 10 + + assert len(flow.wire.acquire_calls) == 1 + + # The probe sees the company's real balance; the gate sees the + + # pre-reservation lease balance and the cost this call just debited. + + assert flow.engine.balance(0) == 5000 + + assert flow.engine.balance(1) == LEASE_SIZE + + assert flow.engine.gate_call is not None + + assert flow.engine.gate_call["options"].credit_cost == {CREDIT_ID: 500} + + assert await flow.remaining() == 500 + + assert await flow.reservations.count() == 1 + + + + async def test_leases_the_credit_the_matched_entitlement_names(self, clock: VirtualClock) -> None: + + # Entitlement-first resolution: the credit and the rate come off the + + # probe's entitlement, whatever the flag's conditions look like. + + ai_entitlement = RulesengineFeatureEntitlement( + + feature_id="feat", + + feature_key=FLAG_KEY, + + value_type="credit", + + credit_id="bilcr_ai", + + consumption_rate=5, + + event_subtype=EVENT_SUBTYPE, + + ) + + engine = FlowEngine( + + probe=_verdict(True, "probe", ai_entitlement), gate=_verdict(True, "matched", ai_entitlement) + + ) + + flow = make_flow(clock, engine=engine) + + flow.wire.acquire_responses[0]["lease"]["lease_id"] = "lse_ai" + + result = await flow.check() + + + + assert result.reservation is not None + + assert result.reservation.credit_type_id == "bilcr_ai" + + assert result.reservation.consumption_rate == 5 + + assert result.reservation.credits_reserved == 250 + + assert flow.wire.acquire_calls[0]["credit_type_id"] == "bilcr_ai" + + assert len(flow.engine.calls) == 2 + + + + async def test_skips_the_lease_when_the_entitlement_is_not_credit_metered(self, clock: VirtualClock) -> None: + + # A boolean grant (an override, say) draws no credit, so the lease path + + # never acquires: the plain check decides, with no reserve to cancel. + + entitlement = RulesengineFeatureEntitlement(feature_id="feat", feature_key=FLAG_KEY, value_type="boolean") + + flow = make_flow(clock, engine=FlowEngine(probe=_verdict(True, "override", entitlement))) + + result = await flow.check() + + + + assert flow.fallback.called is True + + assert result.allowed is True + + assert result.reservation is None + + assert flow.wire.acquire_calls == [] + + assert flow.engine.gate_call is None + + + + async def test_denies_and_refunds_when_the_gate_denies(self, clock: VirtualClock) -> None: + + flow = make_flow(clock, engine=FlowEngine(gate=_verdict(False, "denied_by_targeting"))) + + result = await flow.check() + + + + assert result.allowed is False + + assert result.reason == "denied_by_targeting" + + assert result.reservation is None + + assert await flow.remaining() == LEASE_SIZE + + assert await flow.reservations.count() == 0 + + + + async def test_fails_closed_when_the_acquire_fails(self, clock: VirtualClock) -> None: + + flow = make_flow(clock, acquire="error") + + result = await flow.check(on_acquire_failure="fail-closed") + + + + assert result.allowed is False + + assert result.reason == "lease_acquire_failed" + + assert result.error == "lease_acquire_failed" + + assert result.reservation is None + + assert flow.engine.gate_call is None + + + + async def test_defaults_to_fail_closed(self, clock: VirtualClock) -> None: + + flow = make_flow(clock, acquire="error") + + result = await flow.check() + + + + assert result.allowed is False + + assert result.reservation is None + + + + async def test_fail_open_re_evaluates_with_the_balance_assumed_sufficient(self, clock: VirtualClock) -> None: + + flow = make_flow(clock, acquire="error") + + result = await flow.check(on_acquire_failure="fail-open") + + + + assert result.allowed is True + + assert result.error == "lease_acquire_failed" + + assert result.reservation is None + + # Fail-open runs the rules, it does not skip them: only the credit + + # balance is assumed sufficient, and the caller's usage still rides in. + + assert flow.engine.balance(1) == FAIL_OPEN_BALANCE + + preflight = flow.engine.calls[1]["options"].event_usage + + assert (preflight.event_subtype, preflight.quantity) == (EVENT_SUBTYPE, 50) + + + + async def test_fail_open_still_denies_a_company_the_rules_do_not_entitle(self, clock: VirtualClock) -> None: + + flow = make_flow( + + clock, + + engine=FlowEngine(probe=_verdict(False, "no matching rule", CREDIT_ENTITLEMENT)), + + acquire="error", + + ) + + result = await flow.check(on_acquire_failure="fail-open") + + + + assert result.allowed is False + + assert result.reason == "no matching rule (lease_acquire_failed_fail_open)" + + assert result.error == "lease_acquire_failed" + + + + async def test_fail_open_allows_outright_when_the_re_evaluation_errors(self, clock: VirtualClock) -> None: + + # The probe resolves the credit, the acquire fails, and then the + + # fail-open re-evaluation throws, leaving only the blanket allow. + + flow = make_flow(clock, engine=_ProbeThenExplodes(), acquire="error") + + result = await flow.check(on_acquire_failure="fail-open") + + + + assert result.allowed is True + + assert result.reason == "lease_acquire_failed_fail_open" + + assert result.reservation is None + + + + async def test_falls_back_when_the_probe_errors(self, clock: VirtualClock) -> None: + + # A probe failure is a resolution miss, not the gate: the plain check + + # has its own degradation, and no lease is acquired. + + flow = make_flow(clock, engine=FlowEngine(probe_error=RuntimeError("wasm exploded"))) + + result = await flow.check() + + + + assert flow.fallback.called is True + + assert result.allowed is True + + assert flow.wire.acquire_calls == [] + + + + async def test_falls_back_when_the_flag_is_not_cached(self, clock: VirtualClock) -> None: + + flow = make_flow(clock, missing_flag=True) + + result = await flow.check() + + + + assert flow.fallback.called is True + + assert result.reservation is None + + assert flow.engine.calls == [] + + + + async def test_zero_usage_falls_back_without_a_hold(self, clock: VirtualClock) -> None: + + flow = make_flow(clock) + + result = await flow.check(usage=0) + + + + assert flow.fallback.called is True + + assert result.reservation is None + + assert flow.wire.acquire_calls == [] + + assert flow.engine.calls == [] + + + + async def test_falls_back_when_nothing_names_the_event_subtype(self, clock: VirtualClock) -> None: + + # The hold settles into a track event named by the subtype; without one + + # it could be consumed while billing nothing. + + entitlement = RulesengineFeatureEntitlement( + + feature_id="feat", + + feature_key=FLAG_KEY, + + value_type="credit", + + credit_id=CREDIT_ID, + + consumption_rate=10, + + ) + + flow = make_flow(clock, engine=FlowEngine(probe=_verdict(True, "probe", entitlement))) + + result = await flow.check(event_subtype=None) + + + + assert flow.fallback.called is True + + assert result.reservation is None + + assert flow.wire.acquire_calls == [] + + + + async def test_rejects_a_nan_usage_without_touching_the_lease(self, clock: VirtualClock) -> None: + + # An unguarded NaN debit poisons the shared lease balance into + + # approving every later reserve, since NaN loses every comparison. + + flow = make_flow(clock) + + denied = await flow.check(usage=float("nan")) + + + + assert denied.allowed is False + + assert denied.error == "invalid_usage" + + assert denied.reservation is None + + assert flow.wire.acquire_calls == [] + + assert flow.engine.calls == [] + + + + allowed = await flow.check() + + assert allowed.allowed is True + + assert allowed.reservation is not None + + assert allowed.reservation.credits_reserved == 500 + + + + async def test_resolves_an_invalid_usage_through_fail_open(self, clock: VirtualClock) -> None: + + flow = make_flow(clock) + + result = await flow.check(usage=-10, on_acquire_failure="fail-open") + + + + assert result.allowed is True + + assert result.error == "invalid_usage" + + assert result.reservation is None + + assert flow.wire.acquire_calls == [] + + + + async def test_extends_once_and_retries_when_the_lease_is_short(self, clock: VirtualClock) -> None: + + flow = make_flow(clock) + + await flow.check() # draws the lease down to 500 + + flow.wire.extend_responses.append( + + {"lease": {"granted_total": 2000, "expires_at": clock() + LEASE_DURATION}} + + ) + + result = await flow.check(usage=90) # 900 credits, more than the 500 left + + + + assert result.allowed is True + + assert result.reservation is not None + + assert len(flow.wire.extend_calls) == 1 + + assert await flow.remaining() == 600 + + + + async def test_denies_when_the_retry_after_a_failed_extend_is_still_short(self, clock: VirtualClock) -> None: + + flow = make_flow(clock) + + await flow.check() + + flow.wire.extend_responses.append({"error": "wire down"}) + + result = await flow.check(usage=90) + + + + assert result.allowed is False + + assert result.reason == "insufficient_lease_balance" + + assert result.error == "insufficient_lease_balance" + + assert await flow.remaining() == 500 + + assert await flow.reservations.count() == 1 + + + + + +class TestEntityResolution: + + async def test_threads_a_resolved_user_into_both_evaluations(self, clock: VirtualClock) -> None: + + user = RulesengineUser( + + id="user_1", account_id="acc_1", environment_id="env_1", keys={"id": "user_1"}, traits=[], rules=[] + + ) + + flow = make_flow(clock, user=user) + + result = await check_with_lease( + + flow.deps, + + FLAG_KEY, + + COMPANY, + + {"id": "user_1"}, + + CheckOptions(usage=50, event_subtype=EVENT_SUBTYPE), + + flow.fallback, + + ) + + await flow.manager._drain_background() + + + + assert result.allowed is True + + # Evaluating without the named user would silently skip user-targeted + + # rules and overrides. + + assert [call["user"] for call in flow.engine.calls] == [user, user] + + + + async def test_falls_back_when_the_user_cannot_be_resolved(self, clock: VirtualClock) -> None: + + flow = make_flow(clock, user_error=RuntimeError("DataStream client is not connected")) + + result = await check_with_lease( + + flow.deps, + + FLAG_KEY, + + COMPANY, + + {"id": "user_1"}, + + CheckOptions(usage=50, event_subtype=EVENT_SUBTYPE), + + flow.fallback, + + ) + + + + assert flow.fallback.called is True + + assert result.reservation is None + + assert flow.wire.acquire_calls == [] + + + + async def test_falls_back_when_the_company_cannot_be_resolved(self, clock: VirtualClock) -> None: + + flow = make_flow(clock, company_error=RuntimeError("DataStream client is not connected")) + + result = await flow.check() + + + + assert flow.fallback.called is True + + assert result.reservation is None + + assert flow.wire.acquire_calls == [] + + + + async def test_falls_back_without_datastream(self, clock: VirtualClock) -> None: + + flow = make_flow(clock) + + flow.deps.datastream = None + + result = await flow.check() + + + + assert flow.fallback.called is True + + assert result.reservation is None + + + + + +class TestStoreFailureContainment: + + async def test_a_dead_store_resolves_fail_closed_rather_than_raising(self, clock: VirtualClock) -> None: + + flow = make_flow(clock, lease_store=UnreachableLeaseStore(clock=clock)) + + result = await flow.check() + + + + assert result.allowed is False + + assert result.reason == "lease_store_error" + + assert result.error == "lease_store_error" + + assert result.reservation is None + + + + async def test_a_dead_store_honors_fail_open(self, clock: VirtualClock) -> None: + + flow = make_flow(clock, lease_store=UnreachableLeaseStore(clock=clock)) + + result = await flow.check(on_acquire_failure="fail-open") + + + + assert result.allowed is True + + assert result.reservation is None + + assert flow.engine.balance(1) == FAIL_OPEN_BALANCE + + + + + +class TestCrashWindow: + + async def test_the_debit_lands_before_the_record(self, clock: VirtualClock) -> None: + + """A crash in the gap leaks the debit; it never leaves a record with no + + debit, which a later consume would refund into a double spend.""" + + flow = make_flow(clock) + + seen: Dict[str, Any] = {} + + + + async def freeze(reservation: ReservationRecord) -> None: + + # Freeze the flow where a process death would: reached, never done, + + # so neither the persist nor the undo runs. + + seen["record"] = reservation + + seen["remaining"] = await flow.leases.get(COMPANY["id"], CREDIT_ID) + + seen["reserved"] = await flow.reservations.reserved_credits(COMPANY["id"], CREDIT_ID) + + raise asyncio.CancelledError() + + + + flow.reservations.add = freeze # type: ignore[method-assign] + + with pytest.raises(asyncio.CancelledError): + + await flow.check() + + + + assert seen["record"].credits_reserved == 500 + + assert seen["remaining"].local_remaining_credits == 500 + + # Nothing the sweeper could refund: the leak is bounded by this one + + # hold and reclaimed when the lease expires server-side. + + assert seen["reserved"] == 0 + + assert await flow.reservations.count() == 0 + + assert flow.fallback.called is False + + + + + +class TestSettle: + + async def _reservation(self, flow: Flow) -> Reservation: + + result = await flow.check() + + assert result.reservation is not None + + return result.reservation + + + + async def test_underuse_refunds_the_unspent_slice(self, clock: VirtualClock) -> None: + + flow = make_flow(clock) + + reservation = await self._reservation(flow) + + outcome = await consume_reservation_and_build_event(flow.reservations, reservation, 20) + + + + assert outcome.settled_locally is True + + assert outcome.track.event == EVENT_SUBTYPE + + assert outcome.track.quantity == 20 + + assert outcome.track.lease_id == "lse_1" + + assert outcome.track.reservation_id is None + + assert outcome.track.company == COMPANY + + assert await flow.remaining() == 800 + + + + async def test_overuse_bills_the_actual_but_clamps_the_local_debit(self, clock: VirtualClock) -> None: + + flow = make_flow(clock) + + reservation = await self._reservation(flow) + + outcome = await consume_reservation_and_build_event(flow.reservations, reservation, 120) + + + + # The server is the source of truth for real consumption, so the event + + # bills the unclamped quantity; only the lease's own view is clamped. + + assert outcome.track.quantity == 120 + + assert await flow.remaining() == 500 + + + + async def test_a_settle_after_the_sweep_is_a_recovery_emit(self, clock: VirtualClock) -> None: + + flow = make_flow(clock) + + reservation = await self._reservation(flow) + + clock.advance_ms(60_001) + + assert await flow.reservations.sweep_expired() == 1 + + assert await flow.remaining() == LEASE_SIZE + + + + outcome = await consume_reservation_and_build_event(flow.reservations, reservation, 20) + + + + # The hold was already refunded, so nothing re-debits the consumed + + # slice and the local balance reads high until the lease rolls over. + + assert outcome.settled_locally is False + + assert outcome.track.quantity == 20 + + assert outcome.track.lease_id == "lse_1" + + assert await flow.remaining() == LEASE_SIZE + + + + async def test_a_second_settle_finds_nothing_to_claim(self, clock: VirtualClock) -> None: + + flow = make_flow(clock) + + reservation = await self._reservation(flow) + + await consume_reservation_and_build_event(flow.reservations, reservation, 20) + + outcome = await consume_reservation_and_build_event(flow.reservations, reservation, 20) + + + + assert outcome.settled_locally is False + + assert await flow.remaining() == 800 + + + + async def test_a_fractional_settle_bills_a_whole_unit(self, clock: VirtualClock) -> None: + + flow = make_flow(clock) + + reservation = await self._reservation(flow) + + outcome = await consume_reservation_and_build_event(flow.reservations, reservation, 0.5) + + + + assert outcome.track.quantity == 1 + + + + + +class TestFlagCheckEvents: + + async def test_an_allowed_check_reports_the_engine_verdict(self, clock: VirtualClock) -> None: + + flow = make_flow(clock) + + result = await flow.check() + + + + assert len(flow.flag_checks.events) == 1 + + event = flow.flag_checks.events[0] + + assert event.flag_key == FLAG_KEY + + assert event.value is True + + assert event.reason == result.reason + + assert event.flag_id == "flag_1" + + assert event.company_id == COMPANY["id"] + + assert event.req_company == COMPANY + + assert event.error is None + + + + async def test_a_denied_check_reports_the_denial(self, clock: VirtualClock) -> None: + + flow = make_flow(clock, engine=FlowEngine(gate=_verdict(False, "denied_by_targeting"))) + + await flow.check() + + + + assert len(flow.flag_checks.events) == 1 + + assert flow.flag_checks.events[0].value is False + + assert flow.flag_checks.events[0].reason == "denied_by_targeting" + + + + async def test_a_lease_failure_reports_once(self, clock: VirtualClock) -> None: + + flow = make_flow(clock, acquire="error") + + await flow.check(on_acquire_failure="fail-closed") + + + + assert len(flow.flag_checks.events) == 1 + + assert flow.flag_checks.events[0].value is False + + assert flow.flag_checks.events[0].error == "lease_acquire_failed" + + + + async def test_a_fallback_exit_reports_nothing(self, clock: VirtualClock) -> None: + + # The plain check the lease path defers to reports its own. + + flow = make_flow(clock) + + await flow.check(usage=0) + + + + assert flow.fallback.called is True + + assert flow.flag_checks.events == [] + + + + async def test_a_reporting_failure_leaves_the_verdict_alone(self, clock: VirtualClock) -> None: + + flow = make_flow(clock) + + flow.flag_checks.explode = True + + result = await flow.check() + + + + assert result.allowed is True + + assert result.reservation is not None + + assert await flow.remaining() == 500 + + + + + +class TestPerCheckTimeout: + + async def test_the_timeout_reaches_the_acquire(self, clock: VirtualClock) -> None: + + flow = make_flow(clock) + + await flow.check(timeout=2.5) + + + + assert flow.wire.acquire_calls[0]["timeout"] == 2.5 + + + + async def test_the_timeout_reaches_the_extend_the_reserve_triggers(self, clock: VirtualClock) -> None: + + flow = make_flow(clock) + + await flow.check() # draws the lease down to 500 + + flow.wire.extend_responses.append( + + {"lease": {"granted_total": 2000, "expires_at": clock() + LEASE_DURATION}} + + ) + + await flow.check(usage=90, timeout=2.5) # 900 credits, more than the 500 left + + + + assert flow.wire.extend_calls[0]["timeout"] == 2.5 + + + + async def test_no_timeout_leaves_the_wire_calls_alone(self, clock: VirtualClock) -> None: + + flow = make_flow(clock) + + await flow.check() + + + + assert flow.wire.acquire_calls[0]["timeout"] is None + diff --git a/tests/leases/test_crash_windows.py b/tests/leases/test_crash_windows.py + new file mode 100644 + index 0000000..43f9fac + --- /dev/null + +++ b/tests/leases/test_crash_windows.py + @@ -0,0 +1,207 @@ + +"""The two bounded-leak windows, on both backends. + + + +A crash between the two steps of a transition must strand locally held credits + +(which the server reclaims at lease expiry) rather than enable a double-spend. + +The debit and the claim are durable first; the record and the refund are what + +may be lost. + +""" + + + +from __future__ import annotations + + + +from typing import Any, Awaitable, Tuple, cast + + + +import pytest + +from lease_support import CrashingRefundLeaseStore, VirtualClock, make_fake_redis, make_reservation + + + +from schematic.leases import ( + + InMemoryLeaseStore, + + InMemoryReservationStore, + + LeaseStore, + + RedisLeaseStore, + + RedisReservationStore, + + ReservationStore, + +) + + + +BACKENDS = ("in_memory", "redis") + + + + + +@pytest.fixture(autouse=True) + +def _frozen(frozen_clock: VirtualClock) -> VirtualClock: + + """fakeredis reads TIME from the process clock, so the virtual clock is it.""" + + return frozen_clock + + + + + +def _make_stores( + + backend: str, clock: VirtualClock + +) -> Tuple[LeaseStore, ReservationStore, CrashingRefundLeaseStore]: + + leases: LeaseStore + + reservations: ReservationStore + + if backend == "in_memory": + + leases = InMemoryLeaseStore(clock=clock) + + crash = CrashingRefundLeaseStore(leases) + + reservations = InMemoryReservationStore(crash, clock=clock) + + else: + + client = make_fake_redis() + + leases = RedisLeaseStore(client, clock=clock) + + crash = CrashingRefundLeaseStore(leases) + + reservations = RedisReservationStore(client, crash, clock=clock) + + return leases, reservations, crash + + + + + +async def _seed(leases: LeaseStore, clock: VirtualClock, ttl: float = 60) -> None: + + await leases.replace( + + lease_id="lse_1", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=1000, + + expires_at=clock() + ttl, + + ) + + + + + +async def _balance(leases: LeaseStore) -> float: + + entry = await leases.get("co_1", "ct_1") + + assert entry is not None + + return entry.local_remaining_credits + + + + + +@pytest.mark.parametrize("backend", BACKENDS) + +async def test_debit_without_record_leaks_only_the_hold(backend: str, frozen_clock: VirtualClock) -> None: + + leases, reservations, _crash = _make_stores(backend, frozen_clock) + + await _seed(leases, frozen_clock) + + + + # The crash: the atomic debit landed, the reservation record never did. + + assert await leases.try_reserve("co_1", "ct_1", 100) == 900 + + + + # Exactly the reserved amount is stranded, and it is invisible to the + + # reservation table, so no sweep can ever refund it. + + assert await _balance(leases) == 900 + + assert await reservations.reserved_credits("co_1", "ct_1") == 0 + + assert await reservations.sweep_expired(frozen_clock() + 3600) == 0 + + assert await _balance(leases) == 900 + + + + + +@pytest.mark.parametrize("backend", BACKENDS) + +async def test_debit_leak_is_reclaimed_at_lease_expiry(backend: str, frozen_clock: VirtualClock) -> None: + + leases, _reservations, _crash = _make_stores(backend, frozen_clock) + + await _seed(leases, frozen_clock) + + assert await leases.try_reserve("co_1", "ct_1", 100) == 900 + + + + frozen_clock.advance_ms(60_001) + + # The stale balance is never served again, and the successor installs at + + # the full grant: the leak does not outlive the lease. + + assert await leases.try_reserve("co_1", "ct_1", 1) is None + + assert await leases.replace( + + lease_id="lse_2", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=1000, + + expires_at=frozen_clock() + 120, + + ) + + assert await _balance(leases) == 1000 + + + + + +@pytest.mark.parametrize("backend", BACKENDS) + +async def test_a_retried_check_settles_independently(backend: str, frozen_clock: VirtualClock) -> None: + + leases, reservations, _crash = _make_stores(backend, frozen_clock) + + await _seed(leases, frozen_clock, ttl=3600) + + # The crashed attempt, then the retry with a fresh reservation. + + assert await leases.try_reserve("co_1", "ct_1", 100) == 900 + + assert await leases.try_reserve("co_1", "ct_1", 100) == 800 + + await reservations.add(make_reservation(id="res_retry", expires_at=frozen_clock() + 60)) + + + + assert await reservations.consume("res_retry", 40) == 40 + + # 1000 less the 100 leaked and the 40 consumed: the retry's unspent 60 came + + # back exactly once, the leaked 100 stayed leaked. + + assert await _balance(leases) == 860 + + + + assert await reservations.consume("res_retry", 40) is None + + assert await reservations.sweep_expired(frozen_clock() + 3600) == 0 + + assert await _balance(leases) == 860 + + + + + +@pytest.mark.parametrize("backend", BACKENDS) + +async def test_crash_before_refund_keeps_the_claim(backend: str, frozen_clock: VirtualClock) -> None: + + leases, reservations, crash = _make_stores(backend, frozen_clock) + + await _seed(leases, frozen_clock) + + assert await leases.try_reserve("co_1", "ct_1", 100) == 900 + + await reservations.add(make_reservation(expires_at=frozen_clock() + 60)) + + + + crash.arm() + + with pytest.raises(RuntimeError, match="simulated crash before refund"): + + await reservations.consume("res_1", 30) + + + + # The claim survived, so the reservation is gone everywhere and nothing can + + # double-spend; the 70-credit refund is lost, bounded by the hold. + + assert await reservations.get("res_1") is None + + assert await reservations.reserved_credits("co_1", "ct_1") == 0 + + assert await _balance(leases) == 900 + + + + # Neither a retried settle nor the sweeper can refund a claimed hold. + + assert await reservations.consume("res_1", 30) is None + + assert await reservations.sweep_expired(frozen_clock() + 3600) == 0 + + assert await _balance(leases) == 900 + + + + + +@pytest.mark.parametrize("backend", BACKENDS) + +async def test_crash_before_refund_never_leaks_into_a_successor( + + backend: str, frozen_clock: VirtualClock + +) -> None: + + leases, reservations, crash = _make_stores(backend, frozen_clock) + + await _seed(leases, frozen_clock) + + assert await leases.try_reserve("co_1", "ct_1", 100) == 900 + + await reservations.add(make_reservation(expires_at=frozen_clock() + 60)) + + + + crash.arm() + + with pytest.raises(RuntimeError, match="simulated crash before refund"): + + await reservations.consume("res_1", 30) + + + + frozen_clock.advance_ms(60_001) + + assert await leases.try_reserve("co_1", "ct_1", 1) is None + + assert await leases.replace( + + lease_id="lse_2", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=1000, + + expires_at=frozen_clock() + 120, + + ) + + # A very late retried settle must not push the lost refund into lse_2. + + assert await reservations.consume("res_1", 0) is None + + assert await _balance(leases) == 1000 + + + + + +async def test_crash_after_the_claim_is_reconciled_without_a_refund(frozen_clock: VirtualClock) -> None: + + # Redis only: a process death after the claim but before the index cleanup + + # leaves the per-tenant index over-counting. The sweeper reconciles it, and + + # deliberately does not refund: without the reservation hash, exactly-once + + # cannot be arbitrated across racing sweepers. + + client = make_fake_redis() + + leases = RedisLeaseStore(client, clock=frozen_clock) + + reservations = RedisReservationStore(client, leases, clock=frozen_clock) + + await _seed(leases, frozen_clock) + + assert await leases.try_reserve("co_1", "ct_1", 100) == 900 + + await reservations.add(make_reservation(expires_at=frozen_clock() - 0.001)) + + + + original_evalsha = client.evalsha + + armed = True + + + + async def crash_after_claim(sha: str, numkeys: int, *args: Any) -> Any: + + nonlocal armed + + result = await cast(Awaitable[Any], original_evalsha(sha, numkeys, *args)) + + if armed and isinstance(result, list): + + armed = False + + raise RuntimeError("simulated crash after claim") + + return result + + + + client.evalsha = crash_after_claim # type: ignore[method-assign] + + + + with pytest.raises(RuntimeError, match="simulated crash after claim"): + + await reservations.consume("res_1", 30) + + client.evalsha = original_evalsha # type: ignore[method-assign] + + + + # The orphaned index field still counts the hold... + + assert await reservations.reserved_credits("co_1", "ct_1") == 100 + + # ...until the sweeper reconciles it, without refunding. + + assert await reservations.sweep_expired() == 0 + + assert await reservations.reserved_credits("co_1", "ct_1") == 0 + + assert await reservations.count() == 0 + + assert await _balance(leases) == 900 + diff --git a/tests/leases/test_lease_manager.py b/tests/leases/test_lease_manager.py + new file mode 100644 + index 0000000..a4c0dbf + --- /dev/null + +++ b/tests/leases/test_lease_manager.py + @@ -0,0 +1,404 @@ + +"""Lease manager behavior, ported from the Node SDK's manager tests. + + + +The cross-pod cases share one Redis between two managers, which is the shape + +the store's convergence rules exist for. + +""" + + + +from __future__ import annotations + + + +import asyncio + +from typing import Any, List, Optional + + + +import pytest + +from lease_support import ScriptedWireClient, VirtualClock, make_fake_redis + + + +from schematic.leases import ( + + InMemoryLeaseStore, + + InMemoryReservationStore, + + LeaseConfig, + + LeaseGrant, + + LeaseManager, + + LeaseState, + + LeaseStore, + + RedisLeaseStore, + +) + + + +CONFIG = LeaseConfig(lease_duration=300, reservation_ttl=60, lease_size=1000, low_water_mark=0.25) + + + + + +@pytest.fixture(autouse=True) + +def _frozen(frozen_clock: VirtualClock) -> VirtualClock: + + """The shared-store cases run against fakeredis, which reads TIME from the + + process clock, so the virtual clock has to be that clock here.""" + + return frozen_clock + + + + + +def _lease(clock: VirtualClock, lease_id: str = "lse_1", granted: float = 1000, ttl: float = 300) -> dict: + + return {"lease": {"lease_id": lease_id, "granted_amount": granted, "expires_at": clock() + ttl}} + + + + + +def _make_manager(clock: VirtualClock) -> tuple[LeaseManager, InMemoryLeaseStore, ScriptedWireClient]: + + store = InMemoryLeaseStore(clock=clock) + + wire = ScriptedWireClient() + + manager = LeaseManager(wire, store, config=CONFIG, clock=clock) + + return manager, store, wire + + + + + +async def test_acquire_installs_the_lease(clock: VirtualClock) -> None: + + manager, store, wire = _make_manager(clock) + + wire.acquire_responses.append(_lease(clock)) + + + + entry = await manager.acquire_if_needed("co_1", "ct_1") + + assert len(wire.acquire_calls) == 1 + + assert wire.acquire_calls[0]["requested_amount"] == 1000 + + assert entry is not None and entry.lease_id == "lse_1" and entry.local_remaining_credits == 1000 + + + + + +async def test_acquire_reuses_a_live_lease(clock: VirtualClock) -> None: + + manager, _store, wire = _make_manager(clock) + + wire.acquire_responses.append(_lease(clock)) + + await manager.acquire_if_needed("co_1", "ct_1") + + await manager.acquire_if_needed("co_1", "ct_1") + + assert len(wire.acquire_calls) == 1 + + + + + +async def test_acquire_is_single_flight(clock: VirtualClock) -> None: + + manager, _store, wire = _make_manager(clock) + + gate: "asyncio.Future[None]" = asyncio.get_running_loop().create_future() + + original = wire.acquire + + + + async def slow_acquire(*args: Any, **kwargs: Any) -> LeaseGrant: + + await gate + + return await original(*args, **kwargs) + + + + wire.acquire = slow_acquire # type: ignore[method-assign] + + wire.acquire_responses.append(_lease(clock)) + + + + pending = [asyncio.ensure_future(manager.acquire_if_needed("co_1", "ct_1")) for _ in range(3)] + + await asyncio.sleep(0) + + gate.set_result(None) + + results = await asyncio.gather(*pending) + + + + assert len(wire.acquire_calls) == 1 + + assert [r.lease_id for r in results if r] == ["lse_1"] * 3 + + + + + +async def test_acquire_replaces_an_expired_slot_without_releasing(clock: VirtualClock) -> None: + + manager, store, wire = _make_manager(clock) + + await store.replace( + + lease_id="lse_stale", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=1000, + + expires_at=clock() - 1, + + ) + + wire.acquire_responses.append(_lease(clock, "lse_fresh")) + + + + entry = await manager.acquire_if_needed("co_1", "ct_1") + + await manager._drain_background() + + assert entry is not None and entry.lease_id == "lse_fresh" and entry.local_remaining_credits == 1000 + + # `replace` wrote rather than keeping a live lease, so nothing is redundant. + + assert wire.release_calls == [] + + + + + +async def test_extend_fires_below_the_water_mark(clock: VirtualClock) -> None: + + manager, store, wire = _make_manager(clock) + + wire.acquire_responses.append(_lease(clock)) + + await manager.acquire_if_needed("co_1", "ct_1") + + await store.try_reserve("co_1", "ct_1", 800) + + + + wire.extend_responses.append({"lease": {"granted_total": 2000, "expires_at": clock() + 600}}) + + await manager.maybe_extend("co_1", "ct_1") + + assert len(wire.extend_calls) == 1 + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.granted_amount == 2000 and entry.local_remaining_credits == 1200 + + + + + +async def test_extend_fires_for_required_credits_above_the_water_mark(clock: VirtualClock) -> None: + + manager, store, wire = _make_manager(clock) + + wire.acquire_responses.append(_lease(clock)) + + await manager.acquire_if_needed("co_1", "ct_1") + + await store.try_reserve("co_1", "ct_1", 100) + + + + # Without the hint this is a no-op: 900/1000 is far above the water mark. + + await manager.maybe_extend("co_1", "ct_1") + + assert wire.extend_calls == [] + + + + wire.extend_responses.append({"lease": {"granted_total": 2000, "expires_at": clock() + 600}}) + + await manager.maybe_extend("co_1", "ct_1", 1500) + + assert len(wire.extend_calls) == 1 + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.granted_amount == 2000 + + + + + +async def test_extend_is_sized_to_the_shortfall(clock: VirtualClock) -> None: + + manager, store, wire = _make_manager(clock) + + wire.acquire_responses.append(_lease(clock)) + + await manager.acquire_if_needed("co_1", "ct_1") + + await store.try_reserve("co_1", "ct_1", 100) + + + + # A check needing 5000 credits has a 4100 shortfall, above the configured + + # 1000 tranche: a tranche-sized extend would leave its retry failing + + # forever however much balance the server has. + + wire.extend_responses.append({"lease": {"granted_total": 5100, "expires_at": clock() + 600}}) + + await manager.maybe_extend("co_1", "ct_1", 5000) + + assert wire.extend_calls[-1]["additional_amount"] == 4100 + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.local_remaining_credits == 5000 + + + + + +async def test_never_extends_an_expired_lease(clock: VirtualClock) -> None: + + manager, store, wire = _make_manager(clock) + + await store.replace( + + lease_id="lse_old", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=1000, + + expires_at=clock() - 1, + + ) + + assert await manager.maybe_extend("co_1", "ct_1", 1500) is None + + assert wire.extend_calls == [] + + + + + +async def test_store_failures_resolve_to_no_lease(clock: VirtualClock) -> None: + + class BrokenStore(InMemoryLeaseStore): + + async def get(self, company_id: str, credit_type_id: str) -> Optional[LeaseState]: + + raise RuntimeError("redis down") + + + + wire = ScriptedWireClient() + + manager = LeaseManager(wire, BrokenStore(clock=clock), config=CONFIG, clock=clock) + + # Both are often called fire-and-forget, where a raised exception would + + # surface as an unretrieved task exception. + + assert await manager.acquire_if_needed("co_1", "ct_1") is None + + assert await manager.maybe_extend("co_1", "ct_1") is None + + assert wire.acquire_calls == [] + + assert wire.extend_calls == [] + + + + + +async def test_wire_failures_resolve_to_no_lease(clock: VirtualClock) -> None: + + manager, store, wire = _make_manager(clock) + + wire.acquire_responses.append({"error": "wire down"}) + + assert await manager.acquire_if_needed("co_1", "ct_1") is None + + assert await store.get("co_1", "ct_1") is None + + + + await store.replace( + + lease_id="lse_1", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=1000, + + expires_at=clock() + 300, + + ) + + await store.try_reserve("co_1", "ct_1", 800) + + wire.extend_responses.append({"error": "wire down"}) + + assert await manager.maybe_extend("co_1", "ct_1") is None + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.granted_amount == 1000 and entry.local_remaining_credits == 200 + + + + + +async def test_release_all_releases_live_and_skips_expired(clock: VirtualClock) -> None: + + manager, store, wire = _make_manager(clock) + + await store.replace( + + lease_id="lse_live", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=1000, + + expires_at=clock() + 60, + + ) + + await store.replace( + + lease_id="lse_expired", + + company_id="co_2", + + credit_type_id="ct_1", + + granted_amount=1000, + + expires_at=clock() - 1, + + ) + + await manager.release_all_local_leases() + + assert wire.release_calls == ["lse_live"] + + # The released lease is dropped locally; the expired one is left to lazy + + # expiry. + + assert await store.get("co_1", "ct_1") is None + + assert await store.get("co_2", "ct_1") is not None + + + + + +async def test_release_all_skips_a_shared_store(clock: VirtualClock) -> None: + + # A shared backend cannot enumerate: sibling pods still draw on its leases. + + client = make_fake_redis() + + store = RedisLeaseStore(client, clock=clock) + + wire = ScriptedWireClient() + + manager = LeaseManager(wire, store, config=CONFIG, clock=clock) + + await store.replace( + + lease_id="lse_shared", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=1000, + + expires_at=clock() + 60, + + ) + + await manager.release_all_local_leases() + + assert wire.release_calls == [] + + + + + +async def test_acquire_and_extend_do_not_share_inflight_state(clock: VirtualClock) -> None: + + manager, store, wire = _make_manager(clock) + + gate: "asyncio.Future[None]" = asyncio.get_running_loop().create_future() + + original_extend = wire.extend + + + + async def slow_extend(*args: Any, **kwargs: Any) -> LeaseGrant: + + await gate + + return await original_extend(*args, **kwargs) + + + + wire.extend = slow_extend # type: ignore[method-assign] + + wire.extend_responses.append({"lease": {"granted_total": 2000, "expires_at": clock() + 600}}) + + wire.acquire_responses.append(_lease(clock, "lse_fresh")) + + + + await store.replace( + + lease_id="lse_live", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=1000, + + expires_at=clock() + 300, + + ) + + await store.try_reserve("co_1", "ct_1", 800) + + extending = asyncio.ensure_future(manager.maybe_extend("co_1", "ct_1")) + + await asyncio.sleep(0) + + + + await store.drop("co_1", "ct_1") + + acquired = await manager.acquire_if_needed("co_1", "ct_1") + + assert acquired is not None and acquired.lease_id == "lse_fresh" + + assert len(wire.acquire_calls) == 1 + + + + gate.set_result(None) + + await extending + + + + + +async def test_lost_acquire_race_releases_the_redundant_lease(clock: VirtualClock) -> None: + + # Two managers on one Redis, as two pods would be. Both see an empty slot + + # and acquire; only one lease can hold the slot, and the loser must release + + # the one it minted rather than orphan it against the company balance. + + shared: LeaseStore = RedisLeaseStore(make_fake_redis(), clock=clock) + + pods = [] + + for lease_id in ("lse_a", "lse_b"): + + wire = ScriptedWireClient() + + wire.acquire_responses.append(_lease(clock, lease_id)) + + pods.append((LeaseManager(wire, shared, config=CONFIG, clock=clock), wire)) + + + + entries = await asyncio.gather(*(manager.acquire_if_needed("co_1", "ct_1") for manager, _ in pods)) + + for manager, _ in pods: + + await manager._drain_background() + + + + survivor = await shared.get("co_1", "ct_1") + + assert survivor is not None and survivor.lease_id in ("lse_a", "lse_b") + + assert [entry.lease_id for entry in entries if entry] == [survivor.lease_id] * 2 + + + + released: List[str] = [lease_id for _, wire in pods for lease_id in wire.release_calls] + + loser = "lse_b" if survivor.lease_id == "lse_a" else "lse_a" + + assert released == [loser] + + + + + +async def test_lost_acquire_race_with_the_same_lease_releases_nothing(clock: VirtualClock) -> None: + + # The server is idempotent for an active slot, so a racing acquire is + + # handed back the SAME lease the sibling installed. Releasing it would pull + + # the shared lease out from under every pod. + + shared: LeaseStore = RedisLeaseStore(make_fake_redis(), clock=clock) + + pods = [] + + for _ in range(2): + + wire = ScriptedWireClient() + + wire.acquire_responses.append(_lease(clock, "lse_shared")) + + pods.append((LeaseManager(wire, shared, config=CONFIG, clock=clock), wire)) + + + + entries = await asyncio.gather(*(manager.acquire_if_needed("co_1", "ct_1") for manager, _ in pods)) + + for manager, _ in pods: + + await manager._drain_background() + + + + assert [entry.lease_id for entry in entries if entry] == ["lse_shared", "lse_shared"] + + assert [lease_id for _, wire in pods for lease_id in wire.release_calls] == [] + + + + + +async def test_uncontended_acquire_releases_nothing(clock: VirtualClock) -> None: + + manager, _store, wire = _make_manager(clock) + + wire.acquire_responses.append(_lease(clock)) + + await manager.acquire_if_needed("co_1", "ct_1") + + await manager._drain_background() + + assert wire.release_calls == [] + + + + + +async def test_sweep_loop_runs_and_stops(clock: VirtualClock) -> None: + + leases = InMemoryLeaseStore(clock=clock) + + reservations = InMemoryReservationStore(leases, clock=clock) + + manager = LeaseManager( + + ScriptedWireClient(), + + leases, + + reservation_store=reservations, + + # Short enough that the test does not idle, long enough that the loop + + # cannot run twice before it is stopped. + + config=LeaseConfig(sweep_interval=0.01), + + clock=clock, + + ) + + swept: List[int] = [] + + original = reservations.sweep_expired + + + + async def counting_sweep(now: Optional[float] = None) -> int: + + result = await original(now) + + swept.append(result) + + return result + + + + reservations.sweep_expired = counting_sweep # type: ignore[method-assign] + + + + manager.start_sweep() + + manager.start_sweep() # idempotent + + await asyncio.sleep(0.03) + + assert swept + + manager.stop() + + ticks = len(swept) + + await asyncio.sleep(0.03) + + assert len(swept) == ticks + + + + + +async def test_sweep_loop_survives_a_failing_sweep(clock: VirtualClock) -> None: + + leases = InMemoryLeaseStore(clock=clock) + + reservations = InMemoryReservationStore(leases, clock=clock) + + manager = LeaseManager( + + ScriptedWireClient(), + + leases, + + reservation_store=reservations, + + config=LeaseConfig(sweep_interval=0.01), + + clock=clock, + + ) + + attempts: List[int] = [] + + + + async def failing_sweep(now: Optional[float] = None) -> int: + + attempts.append(1) + + raise RuntimeError("redis blip") + + + + reservations.sweep_expired = failing_sweep # type: ignore[method-assign] + + manager.start_sweep() + + await asyncio.sleep(0.05) + + manager.stop() + + # A transient failure must not kill the loop: the next tick retries. + + assert len(attempts) > 1 + + + + + +async def test_resolve_config_applies_overrides(clock: VirtualClock) -> None: + + from schematic.leases import LeaseConfigOverride + + + + config = LeaseConfig( + + lease_size=500, + + overrides={"ct_special": LeaseConfigOverride(lease_size=25, low_water_mark=0.5)}, + + ) + + manager = LeaseManager(ScriptedWireClient(), InMemoryLeaseStore(clock=clock), config=config, clock=clock) + + + + plain = manager.resolve_config("ct_1") + + assert plain.lease_size == 500 + + assert plain.low_water_mark == 0.25 + + assert plain.lease_duration == 300 + + + + special = manager.resolve_config("ct_special") + + assert special.lease_size == 25 + + assert special.low_water_mark == 0.5 + + + + + +def test_resolve_lease_config_leaves_the_reservation_ttl_alone() -> None: + + from schematic.leases import resolve_lease_config + + + + resolved = resolve_lease_config(LeaseConfig(reservation_ttl=7200.0), None, "ct_1") + + # A client-mode TTL never reaches the server, so the server's cap does not + + # apply to it: it only tells the local sweeper when to refund a hold. + + assert resolved.reservation_ttl == 7200.0 + diff --git a/tests/leases/test_lease_store.py b/tests/leases/test_lease_store.py + new file mode 100644 + index 0000000..574a65d + --- /dev/null + +++ b/tests/leases/test_lease_store.py + @@ -0,0 +1,229 @@ + +"""In-memory lease slot semantics, ported from the Node SDK's store tests.""" + + + +from __future__ import annotations + + + +import asyncio + +import math + + + +import pytest + +from lease_support import VirtualClock + + + +from schematic.leases import InMemoryLeaseStore + + + + + +async def _seed(store: InMemoryLeaseStore, clock: VirtualClock, *, granted: float = 100, ttl: float = 60) -> None: + + await store.replace( + + lease_id="lse_1", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=granted, + + expires_at=clock() + ttl, + + ) + + + + + +@pytest.fixture + +def store(clock: VirtualClock) -> InMemoryLeaseStore: + + return InMemoryLeaseStore(clock=clock) + + + + + +async def test_replace_installs_at_the_full_grant(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + + await _seed(store, clock) + + entry = await store.get("co_1", "ct_1") + + assert entry is not None + + assert entry.granted_amount == 100 + + assert entry.local_remaining_credits == 100 + + + + + +async def test_try_reserve_returns_the_post_debit_balance(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + + await _seed(store, clock) + + assert await store.try_reserve("co_1", "ct_1", 30) == 70 + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.local_remaining_credits == 70 + + + + + +async def test_try_reserve_refuses_without_debiting(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + + await _seed(store, clock) + + assert await store.try_reserve("co_1", "ct_1", 150) is None + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.local_remaining_credits == 100 + + + + + +async def test_try_reserve_refuses_an_expired_lease(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + + await _seed(store, clock) + + clock.advance_ms(60_001) + + assert await store.try_reserve("co_1", "ct_1", 10) is None + + # The balance is stale, not spendable: the server released the lease and + + # refunded its remainder. + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.local_remaining_credits == 100 + + + + + +async def test_try_reserve_rejects_nan_and_infinity(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + + await _seed(store, clock) + + # NaN slips through every comparison, so an unguarded debit would set the + + # balance to NaN and approve every later reserve. + + assert await store.try_reserve("co_1", "ct_1", math.nan) is None + + assert await store.try_reserve("co_1", "ct_1", -10) is None + + assert await store.try_reserve("co_1", "ct_1", math.inf) is None + + assert await store.try_reserve("co_1", "ct_1", 30) == 70 + + assert await store.try_reserve("co_1", "ct_1", 80) is None + + + + + +async def test_refund_caps_at_the_granted_amount(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + + await _seed(store, clock) + + await store.try_reserve("co_1", "ct_1", 30) + + await store.refund("co_1", "ct_1", 20) + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.local_remaining_credits == 90 + + await store.refund("co_1", "ct_1", 9999) + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.local_remaining_credits == 100 + + + + + +async def test_refund_pinned_to_a_stale_lease_is_dropped(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + + await store.replace( + + lease_id="lse_b", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=100, + + expires_at=clock() + 60, + + ) + + await store.try_reserve("co_1", "ct_1", 50) + + await store.refund("co_1", "ct_1", 30, "lse_a") + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.local_remaining_credits == 50 + + await store.refund("co_1", "ct_1", 30, "lse_b") + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.local_remaining_credits == 80 + + + + + +async def test_concurrent_try_reserves_serialize_per_slot(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + + # Per-slot atomicity: three concurrent 40-credit reserves against a + + # 100-credit lease must not oversell it. + + await _seed(store, clock) + + results = await asyncio.gather( + + store.try_reserve("co_1", "ct_1", 40), + + store.try_reserve("co_1", "ct_1", 40), + + store.try_reserve("co_1", "ct_1", 40), + + ) + + assert sorted(r for r in results if r is not None) == [20, 60] + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.local_remaining_credits == 20 + + + + + +async def test_extend_reconciles_to_the_server_total(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + + await _seed(store, clock, ttl=10) + + await store.try_reserve("co_1", "ct_1", 30) + + new_expiry = clock() + 60 + + await store.extend("co_1", "ct_1", 150, new_expiry) + + entry = await store.get("co_1", "ct_1") + + assert entry is not None + + assert entry.granted_amount == 150 + + assert entry.local_remaining_credits == 120 + + assert entry.expires_at == new_expiry + + + + + +async def test_stale_extend_total_is_a_no_op(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + + await _seed(store, clock, ttl=10) + + far_expiry = clock() + 120 + + await store.extend("co_1", "ct_1", 200, far_expiry) + + await store.extend("co_1", "ct_1", 150, clock() + 60) + + entry = await store.get("co_1", "ct_1") + + assert entry is not None + + assert entry.granted_amount == 200 + + assert entry.local_remaining_credits == 200 + + assert entry.expires_at == far_expiry + + + + + +async def test_extend_pinned_to_a_stale_lease_is_dropped(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + + await store.replace( + + lease_id="lse_b", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=100, + + expires_at=clock() + 60, + + ) + + await store.extend("co_1", "ct_1", 150, clock() + 120, "lse_a") + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.granted_amount == 100 and entry.local_remaining_credits == 100 + + await store.extend("co_1", "ct_1", 150, clock() + 120, "lse_b") + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.granted_amount == 150 and entry.local_remaining_credits == 150 + + + + + +async def test_drop_removes_the_slot(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + + await _seed(store, clock) + + await store.drop("co_1", "ct_1") + + assert await store.get("co_1", "ct_1") is None + + + + + +async def test_replace_keeps_a_live_lease_with_a_different_id( + + store: InMemoryLeaseStore, clock: VirtualClock + +) -> None: + + assert await store.replace( + + lease_id="lse_1", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=100, + + expires_at=clock() + 60, + + ) + + await store.try_reserve("co_1", "ct_1", 40) + + wrote = await store.replace( + + lease_id="lse_2", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=100, + + expires_at=clock() + 60, + + ) + + assert wrote is False + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.lease_id == "lse_1" and entry.local_remaining_credits == 60 + + + + + +async def test_replace_overwrites_an_expired_lease(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + + await _seed(store, clock, ttl=60) + + clock.advance_ms(60_001) + + wrote = await store.replace( + + lease_id="lse_2", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=100, + + expires_at=clock() + 60, + + ) + + assert wrote is True + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.lease_id == "lse_2" and entry.local_remaining_credits == 100 + + + + + +async def test_replace_reconciles_an_expired_same_id_lease(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + + # A stale acquire response can hand back the lease already installed (the + + # server is idempotent for an active slot) after the local row expired. + + # Rewriting would reset the balance and erase debits whose reservations are + + # still open. + + await _seed(store, clock, ttl=60) + + await store.try_reserve("co_1", "ct_1", 40) + + clock.advance_ms(61_000) + + later_expiry = clock() + 60 + + wrote = await store.replace( + + lease_id="lse_1", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=150, + + expires_at=later_expiry, + + ) + + assert wrote is False + + entry = await store.get("co_1", "ct_1") + + assert entry is not None + + assert entry.granted_amount == 150 + + assert entry.local_remaining_credits == 110 + + assert entry.expires_at == later_expiry + + + + + +async def test_get_returns_a_snapshot(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + + # Mutating what `get` handed back must not reach the stored slot. + + await _seed(store, clock) + + entry = await store.get("co_1", "ct_1") + + assert entry is not None + + entry.local_remaining_credits = 0 + + stored = await store.get("co_1", "ct_1") + + assert stored is not None and stored.local_remaining_credits == 100 + diff --git a/tests/leases/test_redis_lease_store.py b/tests/leases/test_redis_lease_store.py + new file mode 100644 + index 0000000..d184b55 + --- /dev/null + +++ b/tests/leases/test_redis_lease_store.py + @@ -0,0 +1,298 @@ + +"""Redis lease slot semantics, ported from the Node SDK's Redis store tests. + + + +The shared-backend cases here are the ones the vectors cannot express: two pods + +on one Redis, and rows that are expired but not yet evicted. + +""" + + + +from __future__ import annotations + + + +import asyncio + +import math + +from typing import Any + + + +import pytest + +from lease_support import VirtualClock + + + +from schematic.leases import RedisLeaseStore + +from schematic.leases.redis_lease_store import LEASE_TTL_GRACE_MS + + + + + +@pytest.fixture + +def store(redis_client: Any, frozen_clock: VirtualClock) -> RedisLeaseStore: + + return RedisLeaseStore(redis_client, clock=frozen_clock) + + + + + +async def _seed(store: RedisLeaseStore, clock: VirtualClock, *, granted: float = 100, ttl: float = 60) -> bool: + + return await store.replace( + + lease_id="lse_1", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=granted, + + expires_at=clock() + ttl, + + ) + + + + + +async def test_replace_installs_a_fresh_lease(store: RedisLeaseStore, frozen_clock: VirtualClock) -> None: + + assert await _seed(store, frozen_clock) is True + + entry = await store.get("co_1", "ct_1") + + assert entry is not None + + assert entry.lease_id == "lse_1" + + assert entry.granted_amount == 100 + + assert entry.local_remaining_credits == 100 + + + + + +async def test_replace_preserves_debits_when_the_same_live_lease_is_rewritten( + + store: RedisLeaseStore, frozen_clock: VirtualClock + +) -> None: + + await _seed(store, frozen_clock, granted=1000) + + await store.try_reserve("co_1", "ct_1", 400) + + # A second pod acquires and is handed the same lease back. + + assert await _seed(store, frozen_clock, granted=1000) is False + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.local_remaining_credits == 600 + + + + + +async def test_replace_reconciles_an_expired_same_id_lease( + + store: RedisLeaseStore, frozen_clock: VirtualClock + +) -> None: + + await _seed(store, frozen_clock, granted=1000) + + await store.try_reserve("co_1", "ct_1", 400) + + frozen_clock.advance_ms(61_000) + + later_expiry = frozen_clock() + 60 + + wrote = await store.replace( + + lease_id="lse_1", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=1500, + + expires_at=later_expiry, + + ) + + assert wrote is False + + entry = await store.get("co_1", "ct_1") + + assert entry is not None + + assert entry.granted_amount == 1500 + + assert entry.local_remaining_credits == 1100 + + assert entry.expires_at == later_expiry + + + + + +async def test_replace_keeps_a_different_live_lease(store: RedisLeaseStore, frozen_clock: VirtualClock) -> None: + + # Two pods race the first acquire: the loser must not clobber the winner's + + # already-debited balance. + + await store.replace( + + lease_id="lse_winner", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=1000, + + expires_at=frozen_clock() + 60, + + ) + + await store.try_reserve("co_1", "ct_1", 400) + + wrote = await store.replace( + + lease_id="lse_loser", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=1000, + + expires_at=frozen_clock() + 60, + + ) + + assert wrote is False + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.lease_id == "lse_winner" and entry.local_remaining_credits == 600 + + + + + +async def test_try_reserve_gates_the_shared_balance(store: RedisLeaseStore, frozen_clock: VirtualClock) -> None: + + # Three concurrent 40-credit reserves against a 100-credit lease: the Lua + + # check-and-debit must not oversell it. + + await _seed(store, frozen_clock) + + results = await asyncio.gather( + + store.try_reserve("co_1", "ct_1", 40), + + store.try_reserve("co_1", "ct_1", 40), + + store.try_reserve("co_1", "ct_1", 40), + + ) + + assert sorted(r for r in results if r is not None) == [20, 60] + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.local_remaining_credits == 20 + + + + + +async def test_try_reserve_refuses_an_expired_but_unevicted_row( + + store: RedisLeaseStore, frozen_clock: VirtualClock + +) -> None: + + await _seed(store, frozen_clock, ttl=60) + + # Past the declared expiry but inside the TTL grace, so the row is still + + # readable; the script must still refuse it. + + frozen_clock.advance_ms(60_000 + LEASE_TTL_GRACE_MS / 2) + + assert await store.get("co_1", "ct_1") is not None + + assert await store.try_reserve("co_1", "ct_1", 10) is None + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.local_remaining_credits == 100 + + + + + +async def test_try_reserve_rejects_nan_before_it_reaches_the_script( + + store: RedisLeaseStore, frozen_clock: VirtualClock + +) -> None: + + # The string form of NaN parses back to a Lua nan and would poison the + + # SHARED balance for every pod. + + await _seed(store, frozen_clock) + + assert await store.try_reserve("co_1", "ct_1", math.nan) is None + + assert await store.try_reserve("co_1", "ct_1", -10) is None + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.local_remaining_credits == 100 + + assert await store.try_reserve("co_1", "ct_1", 30) == 70 + + + + + +async def test_fractional_credits_survive_the_round_trip( + + store: RedisLeaseStore, frozen_clock: VirtualClock + +) -> None: + + await _seed(store, frozen_clock, granted=10) + + assert await store.try_reserve("co_1", "ct_1", 2.5) == 7.5 + + await store.refund("co_1", "ct_1", 1.25) + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.local_remaining_credits == 8.75 + + + + + +async def test_refund_caps_at_the_granted_amount(store: RedisLeaseStore, frozen_clock: VirtualClock) -> None: + + await _seed(store, frozen_clock) + + await store.try_reserve("co_1", "ct_1", 30) + + await store.refund("co_1", "ct_1", 9999) + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.local_remaining_credits == 100 + + + + + +async def test_refund_pinned_to_a_stale_lease_is_dropped( + + store: RedisLeaseStore, frozen_clock: VirtualClock + +) -> None: + + await store.replace( + + lease_id="lse_b", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=100, + + expires_at=frozen_clock() + 60, + + ) + + await store.try_reserve("co_1", "ct_1", 50) + + await store.refund("co_1", "ct_1", 30, "lse_a") + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.local_remaining_credits == 50 + + await store.refund("co_1", "ct_1", 30, "lse_b") + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.local_remaining_credits == 80 + + + + + +async def test_extend_falls_back_to_the_configured_duration( + + redis_client: Any, frozen_clock: VirtualClock + +) -> None: + + store = RedisLeaseStore(redis_client, default_lease_duration=0.25, clock=frozen_clock) + + # A short initial expiry so the fallback is a forward move. + + await store.replace( + + lease_id="lse_1", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=100, + + expires_at=frozen_clock() + 0.1, + + ) + + await store.extend("co_1", "ct_1", 150) + + entry = await store.get("co_1", "ct_1") + + assert entry is not None + + assert entry.granted_amount == 150 + + assert entry.expires_at == pytest.approx(frozen_clock() + 0.25) + + + + + +async def test_extend_pinned_to_a_stale_lease_is_dropped( + + store: RedisLeaseStore, frozen_clock: VirtualClock + +) -> None: + + await store.replace( + + lease_id="lse_b", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=100, + + expires_at=frozen_clock() + 60, + + ) + + await store.extend("co_1", "ct_1", 150, frozen_clock() + 120, "lse_a") + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.granted_amount == 100 and entry.local_remaining_credits == 100 + + await store.extend("co_1", "ct_1", 150, frozen_clock() + 120, "lse_b") + + entry = await store.get("co_1", "ct_1") + + assert entry is not None and entry.granted_amount == 150 and entry.local_remaining_credits == 150 + + + + + +async def test_concurrent_sibling_extends_converge_on_the_server_total( + + redis_client: Any, frozen_clock: VirtualClock + +) -> None: + + # Two pods on one Redis. Per-process single-flight cannot serialize them, + + # so both wire calls go out against the same stale read (granted=100); the + + # server lands B's total (150) then A's (200). Each pod applies the TOTAL + + # it was handed, so the slot converges on 200, never 250. + + pod_a = RedisLeaseStore(redis_client, clock=frozen_clock) + + pod_b = RedisLeaseStore(redis_client, clock=frozen_clock) + + await pod_a.replace( + + lease_id="lse_1", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=100, + + expires_at=frozen_clock() + 60, + + ) + + await pod_b.extend("co_1", "ct_1", 150, frozen_clock() + 90, "lse_1") + + await pod_a.extend("co_1", "ct_1", 200, frozen_clock() + 120, "lse_1") + + entry = await pod_a.get("co_1", "ct_1") + + assert entry is not None and entry.granted_amount == 200 and entry.local_remaining_credits == 200 + + + + # Out-of-order arrival: the larger total lands first and the superseded one + + # is a no-op that never pulls the expiry back. + + await pod_a.replace( + + lease_id="lse_2", + + company_id="co_1", + + credit_type_id="ct_2", + + granted_amount=100, + + expires_at=frozen_clock() + 60, + + ) + + far_expiry = frozen_clock() + 120 + + await pod_a.extend("co_1", "ct_2", 200, far_expiry, "lse_2") + + await pod_b.extend("co_1", "ct_2", 150, frozen_clock() + 90, "lse_2") + + entry = await pod_a.get("co_1", "ct_2") + + assert entry is not None + + assert entry.granted_amount == 200 + + assert entry.local_remaining_credits == 200 + + assert entry.expires_at == far_expiry + + + + + +async def test_drop_removes_the_hash(store: RedisLeaseStore, frozen_clock: VirtualClock) -> None: + + await _seed(store, frozen_clock) + + await store.drop("co_1", "ct_1") + + assert await store.get("co_1", "ct_1") is None + + + + + +async def test_key_layout_and_hash_fields_match_the_node_sdk( + + store: RedisLeaseStore, redis_client: Any, frozen_clock: VirtualClock + +) -> None: + + # Node and Python pods share one Redis, so the key, the camelCase field + + # names, and the millisecond instants have to match exactly. + + expires_at = frozen_clock() + 60 + + await store.replace( + + lease_id="lse_1", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=1000, + + expires_at=expires_at, + + ) + + key = store.hash_key("co_1", "ct_1") + + assert key == "schematic:credit-lease:co_1:ct_1" + + raw = await redis_client.hgetall(key) + + assert raw == { + + "leaseId": "lse_1", + + "companyId": "co_1", + + "creditTypeId": "ct_1", + + "grantedAmount": "1000", + + "localRemainingCredits": "1000", + + "expiresAt": str(int(round(expires_at * 1000))), + + } + + # The row outlives its expiry by the grace window so the sweeper can still + + # read it. + + ttl_ms = await redis_client.pttl(key) + + assert 60_000 < ttl_ms <= 60_000 + LEASE_TTL_GRACE_MS + + + + + +async def test_a_flushed_script_cache_falls_back_to_eval( + + store: RedisLeaseStore, redis_client: Any, frozen_clock: VirtualClock + +) -> None: + + # A Redis that restarts (or is flushed) loses the cached script and answers + + # NOSCRIPT; the store re-sends the body rather than failing the reserve. + + await _seed(store, frozen_clock) + + assert await store.try_reserve("co_1", "ct_1", 10) == 90 + + await redis_client.script_flush() + + assert await store.try_reserve("co_1", "ct_1", 10) == 80 + diff --git a/tests/leases/test_redis_reservation_store.py b/tests/leases/test_redis_reservation_store.py + new file mode 100644 + index 0000000..c6271a4 + --- /dev/null + +++ b/tests/leases/test_redis_reservation_store.py + @@ -0,0 +1,226 @@ + +"""Redis reservation table semantics, ported from the Node SDK's tests.""" + + + +from __future__ import annotations + + + +from typing import Any + + + +import pytest + +from lease_support import VirtualClock, make_reservation + + + +from schematic.leases import RedisLeaseStore, RedisReservationStore + +from schematic.leases.redis_reservation_store import RES_TTL_GRACE_MS, SWEEP_BATCH_SIZE + + + + + +@pytest.fixture + +def leases(redis_client: Any, frozen_clock: VirtualClock) -> RedisLeaseStore: + + return RedisLeaseStore(redis_client, clock=frozen_clock) + + + + + +@pytest.fixture + +def reservations(redis_client: Any, leases: RedisLeaseStore, frozen_clock: VirtualClock) -> RedisReservationStore: + + return RedisReservationStore(redis_client, leases, clock=frozen_clock) + + + + + +@pytest.fixture(autouse=True) + +async def seeded_lease(leases: RedisLeaseStore, frozen_clock: VirtualClock) -> None: + + await leases.replace( + + lease_id="lse_1", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=1000, + + expires_at=frozen_clock() + 600, + + ) + + + + + +async def _balance(leases: RedisLeaseStore) -> float: + + entry = await leases.get("co_1", "ct_1") + + assert entry is not None + + return entry.local_remaining_credits + + + + + +async def test_add_round_trips_and_indexes( + + reservations: RedisReservationStore, frozen_clock: VirtualClock + +) -> None: + + await reservations.add(make_reservation(expires_at=frozen_clock() + 60)) + + fetched = await reservations.get("res_1") + + assert fetched is not None + + assert fetched.credits_reserved == 100 + + assert fetched.lease_id == "lse_1" + + assert fetched.company == {"id": "co_1"} + + assert await reservations.count() == 1 + + + + + +async def test_consume_refunds_the_unspent_slice( + + leases: RedisLeaseStore, reservations: RedisReservationStore, frozen_clock: VirtualClock + +) -> None: + + await leases.try_reserve("co_1", "ct_1", 100) + + await reservations.add(make_reservation(expires_at=frozen_clock() + 60)) + + assert await _balance(leases) == 900 + + + + assert await reservations.consume("res_1", 30) == 30 + + assert await _balance(leases) == 970 + + assert await reservations.get("res_1") is None + + + + + +async def test_double_consume_returns_null( + + leases: RedisLeaseStore, reservations: RedisReservationStore, frozen_clock: VirtualClock + +) -> None: + + await leases.try_reserve("co_1", "ct_1", 100) + + await reservations.add(make_reservation(expires_at=frozen_clock() + 60)) + + await reservations.consume("res_1", 50) + + assert await reservations.consume("res_1", 10) is None + + + + + +async def test_sweep_returns_expired_holds_to_the_lease( + + leases: RedisLeaseStore, reservations: RedisReservationStore, frozen_clock: VirtualClock + +) -> None: + + await leases.try_reserve("co_1", "ct_1", 100) + + await reservations.add(make_reservation(expires_at=frozen_clock() - 0.001)) + + assert await reservations.sweep_expired() == 1 + + assert await _balance(leases) == 1000 + + assert await reservations.get("res_1") is None + + + + + +async def test_a_stale_lease_hold_never_inflates_its_successor( + + leases: RedisLeaseStore, reservations: RedisReservationStore, frozen_clock: VirtualClock + +) -> None: + + await leases.try_reserve("co_1", "ct_1", 100) + + await reservations.add(make_reservation(expires_at=frozen_clock() - 0.001)) + + + + await leases.drop("co_1", "ct_1") + + await leases.replace( + + lease_id="lse_2", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=1000, + + expires_at=frozen_clock() + 600, + + ) + + await leases.try_reserve("co_1", "ct_1", 200) + + + + assert await reservations.sweep_expired() == 1 + + assert await _balance(leases) == 800 + + assert await reservations.get("res_1") is None + + + + + +async def test_reserved_credits_sums_open_holds( + + leases: RedisLeaseStore, reservations: RedisReservationStore, frozen_clock: VirtualClock + +) -> None: + + await leases.try_reserve("co_1", "ct_1", 350) + + await reservations.add(make_reservation(id="res_1", credits_reserved=100, expires_at=frozen_clock() + 60)) + + await reservations.add(make_reservation(id="res_2", credits_reserved=250, expires_at=frozen_clock() + 60)) + + await reservations.add( + + make_reservation(id="res_3", credit_type_id="ct_2", credits_reserved=999, expires_at=frozen_clock() + 60) + + ) + + + + assert await reservations.reserved_credits("co_1", "ct_1") == 350 + + assert await reservations.reserved_credits("co_1", "ct_2") == 999 + + + + await reservations.consume("res_1", 40) + + assert await reservations.reserved_credits("co_1", "ct_1") == 250 + + + + + +async def test_every_lua_call_touches_one_key( + + redis_client: Any, leases: RedisLeaseStore, reservations: RedisReservationStore, frozen_clock: VirtualClock + +) -> None: + + # A multi-key script whose keys hash to different slots raises CROSSSLOT on + + # Redis Cluster, so every EVAL these stores send must touch exactly one. + + key_counts = [] + + original_eval = redis_client.eval + + original_evalsha = redis_client.evalsha + + + + async def record_eval(script: str, numkeys: int, *args: Any) -> Any: + + key_counts.append(numkeys) + + return await original_eval(script, numkeys, *args) + + + + async def record_evalsha(sha: str, numkeys: int, *args: Any) -> Any: + + key_counts.append(numkeys) + + return await original_evalsha(sha, numkeys, *args) + + + + redis_client.eval = record_eval + + redis_client.evalsha = record_evalsha + + + + await leases.try_reserve("co_1", "ct_1", 200) + + await leases.refund("co_1", "ct_1", 50) + + await leases.extend("co_1", "ct_1", 1100, frozen_clock() + 120) + + await reservations.add(make_reservation(id="res_a", credits_reserved=100, expires_at=frozen_clock() + 60)) + + await reservations.add( + + make_reservation(id="res_b", credits_reserved=80, expires_at=frozen_clock() - 0.001) + + ) + + await reservations.consume("res_a", 40) + + await reservations.sweep_expired() + + + + assert key_counts + + assert set(key_counts) == {1} + + + + + +async def test_sweep_reconciles_indexes_when_the_hash_has_evicted( + + leases: RedisLeaseStore, reservations: RedisReservationStore, frozen_clock: VirtualClock + +) -> None: + + # A sweeper that goes silent long enough (deploy, restart, starvation) for + + # Redis to evict the reservation hash must still clean both indexes, or the + + # orphaned entry inflates reserved_credits forever. + + await leases.try_reserve("co_1", "ct_1", 100) + + await reservations.add(make_reservation(expires_at=frozen_clock() + 1)) + + assert await reservations.count() == 1 + + assert await reservations.reserved_credits("co_1", "ct_1") == 100 + + + + frozen_clock.advance_ms(1000 + RES_TTL_GRACE_MS + 1) + + + + # Nothing is swept in the refund sense: without the hash, exactly-once + + # cannot be arbitrated across racing sweepers, so the slice waits for the + + # lease to expire server-side. + + assert await reservations.sweep_expired() == 0 + + assert await reservations.count() == 0 + + assert await reservations.reserved_credits("co_1", "ct_1") == 0 + + assert await _balance(leases) == 900 + + + + + +async def test_sweep_drops_an_unparseable_index_member( + + redis_client: Any, reservations: RedisReservationStore, frozen_clock: VirtualClock + +) -> None: + + # Only `add` writes members, so this cannot happen; a member that does not + + # decode must still be removed rather than re-read by every later sweep. + + await redis_client.zadd( + + "schematic:credit-reservations:byExpiry", {"garbage": int(frozen_clock() * 1000) - 1} + + ) + + assert await reservations.sweep_expired() == 0 + + assert await reservations.count() == 0 + + + + + +async def test_sweep_pages_through_a_backlog( + + leases: RedisLeaseStore, reservations: RedisReservationStore, frozen_clock: VirtualClock + +) -> None: + + count = SWEEP_BATCH_SIZE + 44 + + await leases.try_reserve("co_1", "ct_1", count) + + for index in range(count): + + await reservations.add( + + make_reservation(id=f"res_{index}", credits_reserved=1, expires_at=frozen_clock() - 0.001) + + ) + + assert await reservations.sweep_expired() == count + + assert await _balance(leases) == 1000 + + assert await reservations.count() == 0 + + assert await reservations.reserved_credits("co_1", "ct_1") == 0 + + + + + +async def test_key_layout_and_hash_fields_match_the_node_sdk( + + redis_client: Any, reservations: RedisReservationStore, frozen_clock: VirtualClock + +) -> None: + + expires_at = frozen_clock() + 60 + + await reservations.add(make_reservation(expires_at=expires_at)) + + raw = await redis_client.hgetall("schematic:credit-reservation:res_1") + + assert raw == { + + "id": "res_1", + + "leaseId": "lse_1", + + "companyId": "co_1", + + "creditTypeId": "ct_1", + + "eventSubtype": "inference_tokens", + + "quantityReserved": "10", + + "creditsReserved": "100", + + "consumptionRate": "10", + + "expiresAt": str(int(round(expires_at * 1000))), + + "evalCtx": '{"company":{"id":"co_1"}}', + + } + + assert await redis_client.zrange("schematic:credit-reservations:byExpiry", 0, -1) == ["co_1|ct_1|res_1"] + + assert await redis_client.hgetall("schematic:credit-reservations:byCredit:co_1:ct_1") == {"res_1": "100"} + + ttl_ms = await redis_client.pttl("schematic:credit-reservation:res_1") + + assert 60_000 < ttl_ms <= 60_000 + RES_TTL_GRACE_MS + diff --git a/tests/leases/test_reservation_store.py b/tests/leases/test_reservation_store.py + new file mode 100644 + index 0000000..10c5d2f + --- /dev/null + +++ b/tests/leases/test_reservation_store.py + @@ -0,0 +1,133 @@ + +"""In-memory reservation table semantics, ported from the Node SDK's tests.""" + + + +from __future__ import annotations + + + +import pytest + +from lease_support import VirtualClock, make_reservation + + + +from schematic.leases import InMemoryLeaseStore, InMemoryReservationStore + + + + + +@pytest.fixture + +def leases(clock: VirtualClock) -> InMemoryLeaseStore: + + return InMemoryLeaseStore(clock=clock) + + + + + +@pytest.fixture + +def reservations(leases: InMemoryLeaseStore, clock: VirtualClock) -> InMemoryReservationStore: + + return InMemoryReservationStore(leases, clock=clock) + + + + + +@pytest.fixture(autouse=True) + +async def seeded_lease(leases: InMemoryLeaseStore, clock: VirtualClock) -> None: + + await leases.replace( + + lease_id="lse_1", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=1000, + + expires_at=clock() + 60, + + ) + + + + + +async def _balance(leases: InMemoryLeaseStore) -> float: + + entry = await leases.get("co_1", "ct_1") + + assert entry is not None + + return entry.local_remaining_credits + + + + + +async def test_consume_refunds_the_unspent_slice( + + leases: InMemoryLeaseStore, reservations: InMemoryReservationStore, clock: VirtualClock + +) -> None: + + await leases.try_reserve("co_1", "ct_1", 100) + + await reservations.add(make_reservation(expires_at=clock() + 60)) + + assert await _balance(leases) == 900 + + + + assert await reservations.consume("res_1", 30) == 30 + + assert await _balance(leases) == 970 + + assert await reservations.get("res_1") is None + + + + + +async def test_consume_clamps_to_the_hold( + + leases: InMemoryLeaseStore, reservations: InMemoryReservationStore, clock: VirtualClock + +) -> None: + + await leases.try_reserve("co_1", "ct_1", 100) + + await reservations.add(make_reservation(expires_at=clock() + 60)) + + assert await reservations.consume("res_1", 999) == 100 + + assert await _balance(leases) == 900 + + + + + +async def test_consume_of_a_missing_reservation_is_null(reservations: InMemoryReservationStore) -> None: + + assert await reservations.consume("nope", 10) is None + + + + + +async def test_consume_is_exactly_once( + + leases: InMemoryLeaseStore, reservations: InMemoryReservationStore, clock: VirtualClock + +) -> None: + + await leases.try_reserve("co_1", "ct_1", 100) + + await reservations.add(make_reservation(expires_at=clock() + 60)) + + assert await reservations.consume("res_1", 30) == 30 + + assert await reservations.consume("res_1", 30) is None + + assert await _balance(leases) == 970 + + + + + +async def test_a_stale_lease_hold_never_inflates_its_successor( + + leases: InMemoryLeaseStore, reservations: InMemoryReservationStore, clock: VirtualClock + +) -> None: + + await leases.try_reserve("co_1", "ct_1", 100) + + await reservations.add(make_reservation(expires_at=clock() - 0.001)) + + + + # lse_1 expires and lse_2 takes the slot, partially debited. + + await leases.drop("co_1", "ct_1") + + await leases.replace( + + lease_id="lse_2", + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=1000, + + expires_at=clock() + 60, + + ) + + await leases.try_reserve("co_1", "ct_1", 200) + + + + # Sweeping lse_1's hold must not credit lse_2: that slice went back to the + + # company balance when lse_1 expired server-side. + + assert await reservations.sweep_expired() == 1 + + assert await _balance(leases) == 800 + + + + # An explicit consume of a stale-lease hold is dropped the same way. + + await reservations.add(make_reservation(id="res_2", expires_at=clock() + 60)) + + await reservations.consume("res_2", 0) + + assert await _balance(leases) == 800 + + + + + +async def test_sweep_refunds_expired_holds_only( + + leases: InMemoryLeaseStore, reservations: InMemoryReservationStore, clock: VirtualClock + +) -> None: + + await leases.try_reserve("co_1", "ct_1", 100) + + await reservations.add(make_reservation(expires_at=clock() + 10)) + + assert await reservations.sweep_expired() == 0 + + assert await reservations.count() == 1 + + + + clock.advance_ms(10_001) + + assert await reservations.sweep_expired() == 1 + + assert await reservations.get("res_1") is None + + assert await _balance(leases) == 1000 + + + + + +async def test_reserved_credits_sums_the_slot_only(reservations: InMemoryReservationStore) -> None: + + await reservations.add(make_reservation(id="res_1", credits_reserved=100)) + + await reservations.add(make_reservation(id="res_2", credits_reserved=250)) + + await reservations.add(make_reservation(id="res_3", credit_type_id="ct_2", credits_reserved=999)) + + await reservations.add(make_reservation(id="res_4", company_id="co_2", credits_reserved=999)) + + + + assert await reservations.reserved_credits("co_1", "ct_1") == 350 + + assert await reservations.reserved_credits("co_1", "ct_2") == 999 + + assert await reservations.reserved_credits("co_unknown", "ct_1") == 0 + + + + + +async def test_reserved_credits_drops_a_consumed_hold( + + leases: InMemoryLeaseStore, reservations: InMemoryReservationStore, clock: VirtualClock + +) -> None: + + await leases.try_reserve("co_1", "ct_1", 100) + + await reservations.add(make_reservation(expires_at=clock() + 60)) + + assert await reservations.reserved_credits("co_1", "ct_1") == 100 + + await reservations.consume("res_1", 30) + + assert await reservations.reserved_credits("co_1", "ct_1") == 0 + diff --git a/tests/leases/test_wasm_credit_gate.py b/tests/leases/test_wasm_credit_gate.py + new file mode 100644 + index 0000000..90ea574 + --- /dev/null + +++ b/tests/leases/test_wasm_credit_gate.py + @@ -0,0 +1,300 @@ + +"""The credit gate against the real WASM rules engine. + + + +Every other lease test scripts the engine, so the contract that matters most + +goes unexercised: resolving the matched credit entitlement from the probe, + +substituting the lease balance into the company's credit balances, and letting + +the engine's credit_cost gate decide. A drift in the option envelope or in the + +entity shape the SDK feeds the engine fails here rather than mis-gating in + +production. + +""" + + + +from __future__ import annotations + + + +import logging + +from typing import Any, Dict, List, Optional + + + +import pytest + +from lease_support import ScriptedWireClient, VirtualClock + + + +from schematic.client import CheckFlagOptions, CheckOptions, CheckResult, EventUsage + +from schematic.datastream.rules_engine import RulesEngineClient + +from schematic.leases import ( + + CreditCheckDeps, + + InMemoryLeaseStore, + + InMemoryReservationStore, + + LeaseConfig, + + LeaseManager, + + check_with_lease, + +) + +from schematic.types import ( + + RulesengineCompany, + + RulesengineCondition, + + RulesengineFeatureEntitlement, + + RulesengineFlag, + + RulesengineRule, + +) + + + +wasmtime = pytest.importorskip("wasmtime", reason="wasmtime not installed") + + + +FLAG_KEY = "infer" + +CREDIT_ID = "credit-1" + +EVENT_SUBTYPE = "inference_tokens" + +COMPANY_ID = "co" + +LEASE_SIZE = 10_000.0 + + + + + +def _credit_condition() -> RulesengineCondition: + + # A consumption rate of 1 keeps credits equal to quantity, so the + + # arithmetic in the assertions is the engine's own. + + return RulesengineCondition( + + id="cond-credit", + + account_id="acct", + + environment_id="env", + + condition_type="credit", + + operator="lt", + + resource_ids=[], + + trait_value="", + + metric_value=0, + + credit_id=CREDIT_ID, + + consumption_rate=1, + + event_subtype=EVENT_SUBTYPE, + + ) + + + + + +def _company_condition(resource_ids: List[str]) -> RulesengineCondition: + + """A membership condition to flip, so the engine can deny for a reason that + + has nothing to do with the balance.""" + + return RulesengineCondition( + + id="cond-company", + + account_id="acct", + + environment_id="env", + + condition_type="company", + + operator="eq", + + resource_ids=resource_ids, + + trait_value="", + + metric_value=0, + + ) + + + + + +def _credit_flag(extra_conditions: Optional[List[RulesengineCondition]] = None) -> RulesengineFlag: + + return RulesengineFlag( + + id="flag-infer", + + account_id="acct", + + environment_id="env", + + key=FLAG_KEY, + + default_value=False, + + rules=[ + + RulesengineRule( + + id="rule-credit", + + account_id="acct", + + environment_id="env", + + name="Credit", + + rule_type="plan_entitlement", + + priority=100, + + value=True, + + conditions=[_credit_condition(), *(extra_conditions or [])], + + condition_groups=[], + + ) + + ], + + ) + + + + + +def _company( + + credit_balance: float, entitlements: Optional[List[RulesengineFeatureEntitlement]] = None + +) -> RulesengineCompany: + + # The company carries its resolved entitlement for the feature, the shape + + # the DataStream cache holds after a plan assignment. Entitlement-first + + # resolution reads the credit, the rate, and the subtype off it. + + default = RulesengineFeatureEntitlement( + + feature_id="feat-infer", + + feature_key=FLAG_KEY, + + value_type="credit", + + credit_id=CREDIT_ID, + + consumption_rate=1, + + event_subtype=EVENT_SUBTYPE, + + credit_total=credit_balance, + + credit_used=0, + + credit_remaining=credit_balance, + + ) + + return RulesengineCompany( + + id=COMPANY_ID, + + account_id="acct", + + environment_id="env", + + keys={"id": COMPANY_ID}, + + traits=[], + + metrics=[], + + rules=[], + + entitlements=entitlements if entitlements is not None else [default], + + billing_product_ids=[], + + credit_balances={CREDIT_ID: credit_balance}, + + plan_ids=[], + + plan_version_ids=[], + + ) + + + + + +class RealEngineDataStream: + + """Serves a fixed flag and company, and the real rules engine behind them.""" + + + + def __init__(self, engine: RulesEngineClient, flag: RulesengineFlag, company: RulesengineCompany) -> None: + + self._engine = engine + + self._flag = flag + + self._company = company + + + + async def get_flag(self, flag_key: str) -> RulesengineFlag: + + return self._flag + + + + async def get_company(self, keys: Dict[str, str]) -> RulesengineCompany: + + return self._company + + + + async def get_user(self, keys: Dict[str, str]) -> None: + + return None + + + + def evaluate_flag(self, flag: Any, company: Any, user: Any, options: Any = None) -> Any: + + return self._engine.check_flag(flag, company, user, options) + + + + + +def _deps( + + engine: RulesEngineClient, flag: RulesengineFlag, company: RulesengineCompany, clock: VirtualClock + +) -> CreditCheckDeps: + + leases = InMemoryLeaseStore(clock=clock) + + reservations = InMemoryReservationStore(leases, clock=clock) + + wire = ScriptedWireClient() + + wire.acquire_responses.append( + + {"lease": {"lease_id": "lse-1", "granted_amount": LEASE_SIZE, "expires_at": clock() + 60}} + + ) + + manager = LeaseManager( + + wire, + + leases, + + reservation_store=reservations, + + config=LeaseConfig(lease_duration=60.0, reservation_ttl=60.0, lease_size=LEASE_SIZE), + + clock=clock, + + ) + + async def enqueue_flag_check(body: Any) -> None: + + """These tests pin the engine's verdict, not the analytics event.""" + + + + return CreditCheckDeps( + + datastream=RealEngineDataStream(engine, flag, company), + + lease_store=leases, + + reservations=reservations, + + manager=manager, + + logger=logging.getLogger("wasm-credit-gate-test"), + + enqueue_flag_check=enqueue_flag_check, + + clock=clock, + + ) + + + + + +async def _fail_fallback() -> CheckResult: + + raise AssertionError("the lease path should not have fallen back") + + + + + +@pytest.fixture + +async def engine() -> RulesEngineClient: + + client = RulesEngineClient() + + await client.initialize() + + return client + + + + + +class TestCreditGateAgainstTheRealEngine: + + async def test_a_within_balance_usage_passes_and_holds( + + self, engine: RulesEngineClient, clock: VirtualClock + + ) -> None: + + deps = _deps(engine, _credit_flag(), _company(100), clock) + + result = await check_with_lease( + + deps, + + FLAG_KEY, + + {"id": COMPANY_ID}, + + None, + + CheckOptions(usage=50, event_subtype=EVENT_SUBTYPE), + + _fail_fallback, + + ) + + await deps.manager._drain_background() + + + + assert result.allowed is True + + assert result.value is True + + assert result.reservation is not None + + assert result.reservation.credit_type_id == CREDIT_ID + + assert result.reservation.credits_reserved == 50 + + entry = await deps.lease_store.get(COMPANY_ID, CREDIT_ID) + + assert entry is not None and entry.local_remaining_credits == LEASE_SIZE - 50 + + assert await deps.reservations.count() == 1 + + + + async def test_a_non_credit_denial_refunds_the_hold( + + self, engine: RulesEngineClient, clock: VirtualClock + + ) -> None: + + # Credits are plentiful, but the membership condition excludes this + + # company, so the hold taken before the gate has to come back. + + flag = _credit_flag([_company_condition(["some-other-company"])]) + + deps = _deps(engine, flag, _company(10_000), clock) + + result = await check_with_lease( + + deps, + + FLAG_KEY, + + {"id": COMPANY_ID}, + + None, + + CheckOptions(usage=50, event_subtype=EVENT_SUBTYPE), + + _fail_fallback, + + ) + + + + assert result.allowed is False + + assert result.value is False + + assert result.reservation is None + + entry = await deps.lease_store.get(COMPANY_ID, CREDIT_ID) + + assert entry is not None and entry.local_remaining_credits == LEASE_SIZE + + assert await deps.reservations.count() == 0 + + + + async def test_an_override_granted_company_never_touches_the_lease( + + self, engine: RulesEngineClient, clock: VirtualClock + + ) -> None: + + # A company override grants the feature outright, so the company's + + # effective entitlement is boolean rather than credit-metered: no + + # reserve-then-cancel, and no credits billed for usage the override + + # grants for free. + + flag = _credit_flag() + + override = RulesengineRule( + + id="rule-override", + + account_id="acct", + + environment_id="env", + + name="Override", + + rule_type="company_override", + + priority=1, + + value=True, + + conditions=[_company_condition([COMPANY_ID])], + + condition_groups=[], + + ) + + flag = flag.model_copy(update={"rules": [override, *flag.rules]}) + + company = _company( + + 100, + + [RulesengineFeatureEntitlement(feature_id="feat-infer", feature_key=FLAG_KEY, value_type="boolean")], + + ) + + deps = _deps(engine, flag, company, clock) + + fell_back = False + + + + async def fallback() -> CheckResult: + + nonlocal fell_back + + fell_back = True + + return CheckResult(allowed=True, value=True, reason="override", flag_key=FLAG_KEY) + + + + result = await check_with_lease( + + deps, FLAG_KEY, {"id": COMPANY_ID}, None, CheckOptions(usage=50, event_subtype=EVENT_SUBTYPE), fallback + + ) + + + + assert fell_back is True + + assert result.allowed is True + + assert result.reservation is None + + assert await deps.lease_store.get(COMPANY_ID, CREDIT_ID) is None + + assert await deps.reservations.count() == 0 + + + + async def test_the_preflight_envelope_gates_at_the_balance_boundary(self, engine: RulesEngineClient) -> None: + + # The contract the lease path leans on: the SDK's event_usage option + + # reaches the engine as the envelope it gates on. + + flag = _credit_flag() + + company = _company(100) + + + + under = engine.check_flag( + + flag, company, None, CheckFlagOptions(event_usage=EventUsage(event_subtype=EVENT_SUBTYPE, quantity=50)) + + ) + + over = engine.check_flag( + + flag, company, None, CheckFlagOptions(event_usage=EventUsage(event_subtype=EVENT_SUBTYPE, quantity=150)) + + ) + + + + assert under.value is True + + assert over.value is False + diff --git a/tests/leases/test_wire_client.py b/tests/leases/test_wire_client.py + new file mode 100644 + index 0000000..6f49979 + --- /dev/null + +++ b/tests/leases/test_wire_client.py + @@ -0,0 +1,131 @@ + +"""The adapter between the manager and the generated credits client.""" + + + +from __future__ import annotations + + + +import datetime as dt + +from typing import Any, Dict, List, Optional + + + +from schematic.credits.types.acquire_credit_lease_response import AcquireCreditLeaseResponse + +from schematic.credits.types.extend_credit_lease_response import ExtendCreditLeaseResponse + +from schematic.leases import CreditsWireClient, LeaseWireClient + +from schematic.types.credit_lease_response_data import CreditLeaseResponseData + + + +EXPIRES_AT = dt.datetime(2026, 1, 1, 0, 5, tzinfo=dt.timezone.utc) + + + + + +def _lease_data(lease_id: str = "lse_1", granted_amount: float = 1000) -> CreditLeaseResponseData: + + now = dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc) + + return CreditLeaseResponseData( + + id=lease_id, + + company_id="co_1", + + credit_type_id="ct_1", + + granted_amount=granted_amount, + + tracked_amount=0, + + expires_at=EXPIRES_AT, + + created_at=now, + + updated_at=now, + + ) + + + + + +class StubCreditsClient: + + def __init__(self) -> None: + + self.acquire_calls: List[Dict[str, Any]] = [] + + self.extend_calls: List[Dict[str, Any]] = [] + + self.release_calls: List[str] = [] + + + + async def acquire_credit_lease(self, **kwargs: Any) -> AcquireCreditLeaseResponse: + + self.acquire_calls.append(kwargs) + + return AcquireCreditLeaseResponse(data=_lease_data(), params={}) + + + + async def extend_credit_lease(self, lease_id: str, **kwargs: Any) -> ExtendCreditLeaseResponse: + + self.extend_calls.append({"lease_id": lease_id, **kwargs}) + + return ExtendCreditLeaseResponse(data=_lease_data(granted_amount=2000), params={}) + + + + async def release_credit_lease(self, lease_id: str, **kwargs: Any) -> None: + + self.release_calls.append(lease_id) + + + + + +async def test_acquire_maps_the_request_and_the_response() -> None: + + stub = StubCreditsClient() + + wire: LeaseWireClient = CreditsWireClient(stub) + + + + grant = await wire.acquire("co_1", "ct_1", 1000, EXPIRES_AT.timestamp()) + + + + assert stub.acquire_calls[0]["company_id"] == "co_1" + + assert stub.acquire_calls[0]["credit_type_id"] == "ct_1" + + assert stub.acquire_calls[0]["requested_amount"] == 1000 + + assert stub.acquire_calls[0]["expires_at"] == EXPIRES_AT + + assert grant.lease_id == "lse_1" + + assert grant.granted_amount == 1000 + + assert grant.expires_at == EXPIRES_AT.timestamp() + + + + + +async def test_extend_sends_the_additional_amount_and_reads_back_the_total() -> None: + + stub = StubCreditsClient() + + wire: LeaseWireClient = CreditsWireClient(stub) + + + + grant = await wire.extend("lse_1", 500, EXPIRES_AT.timestamp()) + + + + assert stub.extend_calls[0]["lease_id"] == "lse_1" + + assert stub.extend_calls[0]["additional_amount"] == 500 + + # The response carries the server-authoritative TOTAL, not the increment. + + assert grant.granted_amount == 2000 + + + + + +async def test_release_passes_the_lease_id() -> None: + + stub = StubCreditsClient() + + wire: LeaseWireClient = CreditsWireClient(stub) + + await wire.release("lse_1") + + assert stub.release_calls == ["lse_1"] + + + + + +async def test_a_naive_expiry_is_read_as_utc() -> None: + + # A naive timestamp from the API is UTC; reading it as local time would + + # shift every expiry by the pod's offset. + + class NaiveClient(StubCreditsClient): + + async def acquire_credit_lease(self, **kwargs: Any) -> AcquireCreditLeaseResponse: + + data = _lease_data().model_copy(update={"expires_at": EXPIRES_AT.replace(tzinfo=None)}) + + return AcquireCreditLeaseResponse(data=data, params={}) + + + + wire: LeaseWireClient = CreditsWireClient(NaiveClient()) + + grant = await wire.acquire("co_1", "ct_1", 1000, EXPIRES_AT.timestamp()) + + assert grant.expires_at == EXPIRES_AT.timestamp() + + + + + +async def test_request_options_are_threaded_through() -> None: + + stub = StubCreditsClient() + + options: Optional[Any] = {"timeout_in_seconds": 2} + + wire: LeaseWireClient = CreditsWireClient(stub, request_options=options) + + await wire.acquire("co_1", "ct_1", 1000, EXPIRES_AT.timestamp()) + + assert stub.acquire_calls[0]["request_options"] == options + + + + + +async def test_a_per_call_timeout_becomes_request_options() -> None: + + stub = StubCreditsClient() + + wire: LeaseWireClient = CreditsWireClient(stub) + + + + await wire.acquire("co_1", "ct_1", 1000, EXPIRES_AT.timestamp(), 1.5) + + await wire.extend("lse_1", 500, EXPIRES_AT.timestamp(), 1.5) + + + + assert stub.acquire_calls[0]["request_options"] == {"timeout": 1.5} + + assert stub.extend_calls[0]["request_options"] == {"timeout": 1.5} + + + + + +async def test_no_timeout_sends_no_request_options() -> None: + + stub = StubCreditsClient() + + wire: LeaseWireClient = CreditsWireClient(stub) + + + + await wire.acquire("co_1", "ct_1", 1000, EXPIRES_AT.timestamp()) + + await wire.extend("lse_1", 500, EXPIRES_AT.timestamp()) + + + + assert stub.acquire_calls[0]["request_options"] is None + + assert stub.extend_calls[0]["request_options"] is None + + + + + +async def test_a_per_call_timeout_wins_over_the_client_wide_options() -> None: + + stub = StubCreditsClient() + + wire: LeaseWireClient = CreditsWireClient(stub, request_options={"timeout": 30}) + + + + await wire.acquire("co_1", "ct_1", 1000, EXPIRES_AT.timestamp(), 1.5) + + + + assert stub.acquire_calls[0]["request_options"] == {"timeout": 1.5} + theirs_snapshot: + conformance/README.md: | + # Credit lease conformance suite + + `SPEC.md` and `vectors/*.json` are copied verbatim from schematic-node + (`conformance/` on `main`), the reference implementation for client-mode credit + leases. Do not edit them here: fix or extend them in schematic-node and copy the + result back, or the SDKs stop pinning the same behavior. + + `tests/conformance/test_vectors.py` is this repo's runner. The runner is the + only language-specific piece; every SDK reimplements it and must pass the same + vectors. + conformance/SPEC.md: | + # Credit lease & reservation semantics — conformance spec + + This document specifies the client-side credit lease/reservation semantics implemented by the + Schematic Node SDK (the reference implementation), in enough detail to reimplement them in another + language without reading the Node source. The machine-readable test vectors in + `conformance/vectors/*.json` pin the observable behavior; this spec explains the model, the + configuration knobs, and the invariants that cannot be expressed as deterministic vectors. + + Where this document and the vectors disagree, the vectors win — they are generated from the + reference implementation's behavior. + + - [Vector format](#vector-format) + - [Model overview](#model-overview) + - [State](#state) + - [Store operations](#store-operations) + - [Lease manager](#lease-manager) + - [Check flow](#check-flow) + - [Track / settle flow](#track--settle-flow) + - [Configuration knobs](#configuration-knobs) + - [Bounded-leak contract](#bounded-leak-contract) + - [Invariants not expressible as vectors](#invariants-not-expressible-as-vectors) + + ## Vector format + + Each file in `conformance/vectors/` is a JSON document: + + ```json + { + "category": "reservation_lifecycle", + "vectors": [ + { + "name": "unique_snake_case_name", + "description": "What this vector pins and why.", + "backends": ["in_memory", "redis"], + "given": { + "config": { "lease_duration_ms": 300000, "reservation_ttl_ms": 60000, "lease_size": 1000, "low_water_mark": 0.25 }, + "leases": [ { "lease_id": "lse_1", "company_id": "co_1", "credit_type_id": "ct_1", "granted_amount": 1000, "expires_at_ms": 60000 } ] + }, + "operations": [ + { "op": "try_reserve", "company_id": "co_1", "credit_type_id": "ct_1", "credits": 100, "expect": { "balance": 900 } } + ] + } + ] + } + ``` + + Rules: + + - All keys are `snake_case`. Vectors are plain JSON — no language-specific types. + - **Virtual clock.** The run starts at a fixed virtual instant `t0`. Every `*_at_ms` field is an + offset in milliseconds from `t0` (an absolute position on the virtual timeline, not relative to + the current operation). The `advance_clock` operation moves the clock forward; nothing else does. + Runners must execute vectors against a controllable clock (no wall time). + - `backends` restricts which store backends the vector runs against; when omitted, the vector must + pass against every backend the SDK ships (in-memory and Redis for Node). + - `given.leases` are installed via the store's `replace` operation at `t0` (each install must + return "written"). + - Assertions are attached per-operation via `expect`. Final-state assertions are expressed as + trailing read operations (`get_lease`, `reserved_credits`, `reservation_count`). + - `expect.balance` / `expect.consumed` use JSON `null` for the "no / refused" result. + - Reservation ids created by `check` operations are random; the vector names them via + `save_reservation_as` and later operations reference them with `handle`. + + ### Operations + + Store-level (exercise the lease store and reservation store directly): + + | op | fields | expect | + | --- | --- | --- | + | `advance_clock` | `ms` | — | + | `replace_lease` | `lease_id`, `company_id`, `credit_type_id`, `granted_amount`, `expires_at_ms` | `written` (bool) | + | `drop_lease` | `company_id`, `credit_type_id` | — | + | `try_reserve` | `company_id`, `credit_type_id`, `credits` | `balance` (post-debit number, or `null`) | + | `refund_lease` | `company_id`, `credit_type_id`, `credits`, `pin_lease_id`? | — | + | `extend_lease` | `company_id`, `credit_type_id`, `granted_total`, `expires_at_ms`?, `pin_lease_id`? | — | + | `get_lease` | `company_id`, `credit_type_id` | `exists`, `lease_id`?, `granted_amount`?, `local_remaining_credits`? | + | `add_reservation` | `id`, `lease_id`, `company_id`, `credit_type_id`, `event_subtype`, `quantity_reserved`, `credits_reserved`, `consumption_rate`, `expires_at_ms` | — | + | `consume_reservation` | `id` or `handle`, `credits`, `crash_before_refund`? (bool, one-shot) | `consumed` (number or `null`), `throws`? | + | `get_reservation` | `id` or `handle` | `exists` | + | `reserved_credits` | `company_id`, `credit_type_id` | `total` | + | `reservation_count` | — | `count` | + | `sweep_expired` | — | `swept` | + + Manager-level (exercise the lease manager with a scripted wire client): + + | op | fields | expect | + | --- | --- | --- | + | `acquire_if_needed` | `company_id`, `credit_type_id`, `server`? ( `{ "lease": {...} }` or `{ "error": "..." }` ), `install_during_wire`? (lease installed into the store while the wire call is in flight, emulating a sibling pod winning the race) | `lease_id` (or `null`), `wire_acquires` (cumulative count), `last_acquire_requested_amount`?, `released_lease_ids` (cumulative) | + | `maybe_extend` | `company_id`, `credit_type_id`, `required_credits`?, `server`? ( `{ "lease": { "granted_total", "expires_at_ms" } }` or `{ "error": "..." }` ) | `wire_extends` (cumulative count), `last_extend_additional_amount`?, `last_extend_lease_id`? | + | `release_all_local_leases` | — (in-memory backend only) | `released_lease_ids`, `remaining_slots` | + + Flow-level (exercise the full check/track orchestration with a scripted rules engine): + + | op | fields | expect | + | --- | --- | --- | + | `check` | `flag_key`, `company` (`{ id, credit_balances }`), `usage`, `event_subtype`?, `on_acquire_failure`?, `engine` (array of scripted engine results, consumed in call order), `server`? (as above), `save_reservation_as`? | `allowed`, `reason`?, `err`?, `has_reservation`, `reservation`? (field subset), `fallback_called`?, `engine_calls`? (per-call `{ credit_balance, credit_cost?, event_usage? }`; `credit_balance` may be the string `"max_safe_integer"`) | + | `track` | `handle`, `actual_quantity` | `settled_locally`, `track` (`{ event, quantity, lease_id }`) | + + A scripted engine result is `{ "value": bool, "reason"?: string, "entitlement"?: { "value_type", + "credit_id"?, "consumption_rate"?, "event_subtype"?, "feature_id"?, "feature_key"? } }`. The engine + is an oracle: the vectors pin the *orchestration around* the rules engine (what it is called with, + and what the SDK does with its answer), not the engine itself — the engine is shared WASM across + SDKs and has its own tests. + + ## Model overview + + Credit-metered features are gated client-side without a wire call per check. The SDK: + + 1. **Leases** a tranche of credits from the server per `(company_id, credit_type_id)`. The server + pre-debits the company balance by the granted amount; the SDK tracks a local view of how much + of the tranche remains un-reserved (`local_remaining_credits`). + 2. **Reserves** `usage x consumption_rate` credits from the lease at `check()` time, atomically + (check-and-debit). A successful, engine-approved check returns a *reservation handle*. + 3. **Settles** the reservation at `track()` time with the actual usage: the actually-consumed + credits stay debited, the unspent slice is refunded to the lease, and a Track event bills the + server (the server is the source of truth for real consumption). + + Everything client-side is *local bookkeeping against the leased tranche*. The server reconciles: + an expired lease's unspent remainder is refunded to the company balance server-side, and Track + events (keyed by `lease_id`) drive the authoritative consumption. + + Leases and reservations both expire: + + - A **lease** past its expiry must be treated as *released* — its local balance is stale (the + server already refunded the remainder) and must never serve another reserve or be extended. + - A **reservation** past its TTL is swept: removed from the table and its full hold refunded to + the lease. Work that finishes after the sweep still bills the server (recovery emit) but does + not re-debit the local lease. + + ## State + + **Lease slot** — at most one lease per `(company_id, credit_type_id)` key: + + | field | meaning | + | --- | --- | + | `lease_id` | Server-issued id. | + | `granted_amount` | Server-authoritative total granted to this lease (grows on extend). | + | `local_remaining_credits` | Local view: granted minus outstanding holds/consumption. Initialized to `granted_amount` on install. | + | `expires_at` | Expiry instant. Past it the lease is dead (see above). | + + **Reservation** — keyed by a unique id: + + | field | meaning | + | --- | --- | + | `id` | Unique (UUID in Node). | + | `lease_id` | The lease the hold was carved from. Pins refunds. | + | `company_id`, `credit_type_id` | Slot key. | + | `event_subtype` | Event the settle will bill as. | + | `quantity_reserved` | Caller-declared usage (event units). | + | `credits_reserved` | `quantity_reserved x consumption_rate`. | + | `consumption_rate` | Rate at reservation time. | + | `expires_at` | Reservation TTL deadline (sweep target). | + | `eval_ctx` | Company/user keys used at check time; threaded onto the Track event. | + + ## Store operations + + These are the primitives both store backends (per-process in-memory; shared Redis) must implement + with identical observable semantics. Each mutation must be atomic per slot/reservation (see + [Invariants](#invariants-not-expressible-as-vectors)). + + ### `replace(lease)` — install-if-not-live + + Install a fresh lease with `local_remaining_credits = granted_amount`, **only if** the slot is + empty or the existing lease is expired *and carries a different `lease_id`*. If a *live* lease + occupies the slot — even with a different `lease_id` (a sibling pod won the race) — leave it + untouched (its already-debited balance wins) and report "kept". If an *expired* lease with the + **same** `lease_id` occupies the slot (a stale acquire response for a lease the idempotent server + also handed to a racing sibling, which may since have extended it), do not rewrite it either: + rewriting would reset `local_remaining_credits` to the full grant and erase debits whose + reservations are still open. Reconcile it like `extend` instead — granted to the incoming total + (lower/equal totals are no-ops), expiry only forward, balance untouched — and report "kept". + Returns written/kept so the caller can run the redundant-lease release logic (see manager). + + ### `try_reserve(company, credit, credits)` — atomic check-and-debit + + - Reject (return `null`, touch nothing) if: no lease in the slot; the lease is **expired**; the + remaining balance is `< credits`; or `credits` is not a finite non-negative number (NaN must + never reach the arithmetic — it slips through every comparison and would poison the balance + into approving everything). + - Otherwise debit and return the **post-debit balance** (so the caller can derive the pre-debit + figure as `returned + credits` without a racy follow-up read). + - Reserving down to exactly 0 is allowed. + + ### `refund(company, credit, credits, pin_lease_id?)` + + Add credits back to `local_remaining_credits`, **clamped at `granted_amount`**. No-op if + `credits <= 0` or no lease is in the slot. When `pin_lease_id` is given, the refund applies + **only if** the slot still holds that lease: a hold carved out of expired lease A must never + inflate successor lease B — A's remainder (including this slice) was already refunded to the + company balance server-side when A expired, so crediting B would double-count. + + ### `extend(company, credit, granted_total, new_expires_at?, pin_lease_id?)` — reconcile to total + + After a remote extend, reconcile the slot to the **server-authoritative total**: + + - Compute `delta = granted_total - stored granted_amount` **atomically against the currently + stored total** — never from a caller-held pre-wire-call read (two pods extending concurrently + from the same stale read would each apply a delta and mint phantom credits). If `delta > 0`, + set `granted_amount = granted_total` and add `delta` to `local_remaining_credits`. If + `delta <= 0` (a total a sibling already applied, or a stale lower total) it is a **no-op** — + applies converge in any order. + - Expiry only ever moves **forward**: `new_expires_at` is applied only if later than the stored + expiry, so an out-of-order apply cannot shorten a lease a sibling just extended. + - When `pin_lease_id` is given and the slot holds a different lease, drop the whole extend + (credits and expiry): the server granted the extension to the pinned lease; crediting a + successor would mint credits the server refunds with the pinned lease at its expiry. + - No-op if the slot is empty. + + ### `drop(company, credit)` + + Remove the slot entry (after a remote release). Plain delete. + + ### Reservation table: `add`, `get`, `consume`, `reserved_credits`, `sweep_expired` + + - `add(reservation)` — register. Idempotent on id. `add` does NOT debit the lease; the debit + already happened in `try_reserve` (see [ordering](#bounded-leak-contract)). + - `consume(id, credits_consumed)` — **exactly-once claim**: atomically remove the reservation + from the table; if it was already gone (swept, or consumed by a racing caller) return `null` + and touch nothing. On a successful claim, clamp `credits_consumed` to + `[0, credits_reserved]`, refund `credits_reserved - clamped` to the lease (pinned to the + reservation's `lease_id`), and return the clamped figure. The claim and the refund are two + steps; the claim is the arbiter (see bounded-leak contract). + - `reserved_credits(company, credit)` — sum of `credits_reserved` across open reservations for + the slot. A reservation counts iff it is still in the table, so + `local_remaining_credits + reserved_credits` stays exact between operations. + - `sweep_expired(now)` — remove every reservation with `expires_at <= now` and refund its full + hold to its lease (pinned to its `lease_id`; a stale-lease hold is dropped, not refunded). + Returns the number swept. Runs on a background interval (`sweep_interval_ms`) in production; + vectors call it explicitly. + + ## Lease manager + + Owns the lease lifecycle against the server wire API (`acquire`, `extend`, `release`). + + ### Acquire (`acquire_if_needed`) + + - If the slot holds a **live** lease, return it — no wire call. + - Otherwise call the server: `requested_amount = lease_size`, `expires_at = now + + lease_duration_ms`. An expired local entry is left in place for `replace` to overwrite + atomically (deleting it first would open a race window against sibling pods; every reader + re-guards on expiry anyway). + - On response, `replace` the slot. If `replace` kept an existing lease (a sibling won, or the + slot's expired row was reconciled in place): + - If the installed lease has a **different id** than the one the server handed us, ours is a + redundant hold nobody will draw on — release it (fire-and-forget; a failed release falls + back to server-side lease expiry). + - If the ids are the **same** (the server is idempotent for an active slot and handed the + racing acquire the sibling's lease back), release **nothing** — releasing would pull the + shared lease out from under every sibling. + - If the slot reads empty (expired in the gap), also release nothing. + - Either way, return whatever the slot now holds. + - Wire or store failure: return "no lease" (never throw) — the caller routes it through + fail-open/fail-closed. + - Per-process single-flight per slot: concurrent callers share one in-flight wire call + (best-effort; duplicates are absorbed by the idempotent server + `replace`). + + ### Extend (`maybe_extend`) + + Triggered when EITHER: + + - `local_remaining_credits / max(granted_amount, 1) <= low_water_mark` (steady-state refresh), or + - the caller passes `required_credits` and `local_remaining_credits < required_credits` (a check + just failed a reserve of that size — extend opportunistically). + + Rules: + + - **Never extend an expired lease** — the server treats it as released; the right move is a fresh + acquire on the next check. + - Wire body: `additional_amount = max(lease_size, required_credits - local_remaining_credits)`. + Sizing to the shortfall matters: a single check needing more than `remaining + lease_size` + would otherwise fail its post-extend retry forever regardless of server balance. + `expires_at = now + lease_duration_ms`. + - On response, reconcile via the store's `extend` with the server's **total** and new expiry, + **pinned** to the extended lease's id. + - Failures resolve to "no lease" without throwing (often fire-and-forget). + - Per-process single-flight per slot, kept separate from acquire's (an in-flight extend must not + satisfy an acquire, or vice versa). + + ### Release on close (`release_all_local_leases`) + + Only for a **per-process (in-memory) store**, whose leases are exclusively this process's: + release every live lease over the wire (returning the unspent remainder to the company balance + immediately) and drop it locally; **skip expired** leases (already swept server-side). A shared + (Redis) store must never do this — sibling pods still draw on those leases. Best-effort: + failures fall back to server-side expiry. + + ## Check flow + + `check(eval_ctx, flag_key, { usage, event_subtype?, on_acquire_failure?, ... })` — the + lease-gated feature check. Fallback = the plain (non-lease) flag check, which has its own + degradation story; when the flow "falls back", no reservation is issued and no lease state is + touched beyond what already happened. + + Guards, in order: + + 1. `usage` missing → plain check (lease path not requested). + 2. `usage` not a finite non-negative number → resolve **statically** by `on_acquire_failure` + (deny for fail-closed; blanket allow for fail-open, reason `invalid_usage`). The value must + never reach the stores. + 3. `usage == 0` → nothing to reserve; fall back to the plain check (no 0-credit reservation). + 4. No datastream / cached flag / resolvable company (or named user) → fall back. + + Then: + + 5. **Entitlement probe.** Run the rules engine once against the company's *real* balance — no + substitution, no credit-cost preflight (a preflight against the lease-depleted server balance + could fail the credit condition and hide the entitlement being probed for). Read the matched + entitlement's shape: + - Not credit-metered (`value_type != "credit"`: boolean/override grant, numeric allocation, + unlimited, or not entitled) → **fall back**, no lease traffic at all. + - Credit entitlement missing `credit_id`, a positive `consumption_rate`, or a resolvable + `event_subtype` (caller's explicit subtype wins over the entitlement's) → fall back. + - Probe error → fall back (it is a resolution step, not the gate). + 6. `credit_cost = usage x consumption_rate`. + 7. **Acquire** a lease for `(company, credit_id)`. Failure → [failure handling](#failure-handling) + with reason `lease_acquire_failed`. + 8. **Reserve** `credit_cost` via `try_reserve`. On refusal, opportunistically + `maybe_extend(required_credits = credit_cost)` (awaited) and retry the reserve **once**. + Still refused → failure handling, reason `insufficient_lease_balance`. Store error → + failure handling, reason `lease_store_error`. + 9. **Record the reservation** (TTL = `reservation_ttl_ms` from now) — *after* the debit, *before* + the engine gate. If persisting fails, undo the debit (claim-and-refund; direct refund if + nothing persisted; both pinned to the lease) and go to failure handling + (`lease_store_error`). If even the undo fails, accept the bounded leak. + 10. **Engine gate.** Re-run the engine against a company snapshot whose + `credit_balances[credit_id]` is substituted with the **pre-reservation** local balance + (post-debit balance returned by `try_reserve` + `credit_cost` — exact as of the debit, no + read race), passing `credit_cost = { credit_id: credit_cost }` so the engine evaluates + `pre_reservation - credit_cost >= 0` — the same arithmetic `try_reserve` just enforced, plus + every non-credit rule (plan targeting, overrides). + - Engine **allows** → keep the hold; return `{ allowed: true, reservation }`. Fire-and-forget + a watermark-driven `maybe_extend`. + - Engine **denies** → cancel the reservation (claim + full refund) and return + `{ allowed: false }` with the engine's reason. + - Engine **errors** → cancel the reservation and resolve **statically** by mode (the engine + itself is down, so no fail-open re-evaluation is possible). + + ### Failure handling + + Every can't-gate outcome (acquire failed, store unreachable, lease exhausted) funnels through the + configured `on_acquire_failure` mode (default **fail-closed**): + + - **fail-closed** → `{ allowed: false }`, reason = the failure reason. No reservation. + - **fail-open** → *err on the side of assuming the credits are there*, **not** blanket allow: + re-run the engine with the credit balance substituted to an effectively unlimited value + (`MAX_SAFE_INTEGER` in Node) and the caller's usage preflight threaded through. Plan + targeting, overrides, and every non-credit condition still apply — a company that is not + entitled stays **denied** even with the lease backend down. No reservation is issued either + way; `err` carries the failure reason. Only if that evaluation itself errors does the SDK + fall back to a blanket allow. + + ## Track / settle flow + + `track_with_reservation(reservation, actual_quantity)` settles a reservation: + + 1. `credits = actual_quantity x reservation.consumption_rate`. + 2. `consume(reservation.id, credits)`: + - **Settled locally** (claim succeeded): the clamped consumed slice stays debited; the unspent + slice is refunded to the lease (pinned). + - **Not settled** (`null`: already swept after TTL, already consumed, or store unreachable): + local lease state is untouched — if the sweeper already refunded the full hold, nothing + re-debits the consumed slice, so the local balance reads **high** until the lease rolls + over. This is why `reservation_ttl_ms` should exceed the longest expected gap between + `check()` and `track_with_reservation()`. + 3. Either way, emit the Track event built from the **caller-held handle** (not the store): + `event = event_subtype`, `quantity = actual_quantity` (the *unclamped* actual — the server is + the source of truth for real consumption; only local bookkeeping clamps to the reserved + amount), `lease_id = reservation.lease_id` (routes the server-side consumption through the + lease's sub-ledger instead of double-debiting the pre-debited grant), plus the reservation's + `eval_ctx` company/user and any caller traits. + 4. The Track carries a deterministic idempotency key derived from the reservation id + (`"lease-reservation:" + reservation.id` in Node); the server dedupes by it for 24h, so a + recovery emit racing the normal emit, or an accidental double settle, collapses to one billed + event across pods and restarts. + 5. Guard: a non-finite or negative `actual_quantity` skips the settle entirely (no store call, no + event) — the untouched reservation expires at its TTL and the sweeper refunds the full hold. + + ## Configuration knobs + + | knob (vector key) | Node name | default | meaning | + | --- | --- | --- | --- | + | `lease_duration_ms` | `defaultLeaseDuration` | 300 000 (5 min) | Lease lifetime requested at acquire/extend (`expires_at = now + duration`). | + | `reservation_ttl_ms` | `defaultReservationTTL` | 60 000 (60 s) | Reservation lifetime; the sweep deadline. Size above the longest expected check→track gap. | + | `lease_size` | `defaultLeaseSize` | 10 000 | Credits requested per acquire, and the minimum extend tranche. | + | `low_water_mark` | `lowWaterMark` | 0.25 | Remaining/granted ratio at or below which a background extend is kicked off. | + | `sweep_interval_ms` | `sweepIntervalMs` | 1 000 | Expired-reservation sweep cadence. | + | — | `onAcquireFailure` | `fail-closed` | Per-check failure mode (see check flow). | + + Per-credit-type overrides of the first four are supported (keyed by credit type id); resolution is + override → client config → default. + + ## Bounded-leak contract + + The flow deliberately orders its two-step transitions so that a process crash between steps leaks + *locally held credits* (which the server reclaims at lease expiry) rather than enabling a + double-spend. The invariant direction is always: **the debit/claim is durable first; the + record/refund may be lost.** + + | # | crash window | what leaks | bound | reclaimed by | must NOT happen | + | --- | --- | --- | --- | --- | --- | + | 1 | after `try_reserve` (debit), before `add` (record) | the debited hold — invisible to the reservation table, so the sweeper can never refund it | `credits_reserved` of that one check | lease expiry: the expired balance is never served again, and the server refunds the whole grant; the successor lease installs at full grant | a reservation record without a debit (a later consume would refund credits never held → double-spend). Vectors pin that the debit lands strictly before the record. | + | 2 | inside `consume`: after the claim, before the refund | the unspent slice of that reservation | `credits_reserved` of that one reservation | lease expiry (same mechanism) | a double refund: the claim is exactly-once, so a retried settle or a sweeper finds nothing to claim and refunds nothing | + | 3 | (Redis only) after the claim, before index cleanup | nothing (bookkeeping only): the per-slot reserved-credits index transiently over-counts | one index field | the sweeper reconciles the orphaned index entry — **without refunding** (without the claimed record, exactly-once cannot be arbitrated across racing sweepers) | a refund driven by an index entry alone | + + Additional pinned properties: + + - A leak never survives its lease: after lease expiry the stale balance is refused + (`try_reserve → null`) and a successor lease restores the full grant. + - A retried check after a window-1 crash settles independently: its own slice refunds exactly + once; the leaked slice never refunds. + - A late retried settle after a window-2 crash (even after a successor lease is installed) + refunds nothing into the successor. + + ## Invariants not expressible as vectors + + These hold in the reference implementation but need concurrency, wall clocks, or non-JSON values + to demonstrate; ports must uphold them and should test them natively. + + 1. **Per-slot atomicity.** `replace`, `try_reserve`, `refund`, `extend` are atomic per lease + slot; `consume`'s claim is atomic per reservation. In-memory: per-key mutual exclusion. Redis: + single-key Lua scripts (single-key keeps them Redis-Cluster-safe; the refund to the lease hash + is deliberately a separate single-key step, never a multi-key script — see leak window 2/3). + 2. **Server-clock expiry (shared backend).** With a shared store, lease expiry must be decided + against the *store's* clock (Redis `TIME`), not the calling process's — pods with skewed + clocks must agree on liveness. Backend rows carry a TTL grace window past `expires_at` + (60 s lease / 30 s reservation in Node) so the sweeper can still read them; expired-but-not- + evicted rows must still refuse reserves. + 3. **NaN/precision guards.** Non-finite or negative amounts are rejected at every boundary + (`usage`, `try_reserve`, `actual_quantity`) — JSON cannot encode NaN, so vectors only cover + the negative case. Fractional credit amounts are legal throughout (rates like 0.1); Redis + stores balances as strings to avoid integer truncation. + 4. **Single-flight.** Per-process, per-slot single-flight for acquire and for extend, tracked + separately. Best-effort only: duplicate wire calls are safe (idempotent server + keep-first + `replace` + reconcile-to-total `extend`). + 5. **Concurrent cross-pod extends converge.** Two pods extending from the same stale read must + not double-count — guaranteed by reconcile-to-total computed inside the store (the sequential + out-of-order-totals vector pins the arithmetic; the concurrent schedule needs a race). + 6. **Idempotent billing.** The Track idempotency key is deterministic from the reservation id; + the server dedupes for 24h. Double settles and recovery emits collapse to one billed event. + 7. **Background sweep loop.** `start_sweep`/`stop` run `sweep_expired` on an interval; timers + must not keep the process alive. Vectors call `sweep_expired` explicitly instead. + 8. **Fire-and-forget never rejects.** `acquire_if_needed`, `maybe_extend`, and release paths + resolve (to "no lease") on failure rather than rejecting — they are often unawaited. + 9. **Offline/unconfigured degradation.** Lease config absent → `check` is a plain flag check; + `track_with_reservation` on an unconfigured client still emits the billing event with the + `lease_id` and idempotency key intact. + conformance/vectors/check-flow.json: | + { + "category": "check_flow", + "vectors": [ + { + "name": "check_happy_path_gates_on_pre_reservation_balance", + "description": "A lease-bearing check: probe against the real balance (no substitution, no credit cost), reserve usage x rate from the lease, then gate the engine on the PRE-reservation local balance with credit_cost — the same arithmetic the atomic reserve just enforced. The hold sticks only because the engine allowed.", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + }, + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 300000 + } + ] + }, + "operations": [ + { + "op": "check", + "flag_key": "inference", + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + "usage": 10, + "event_subtype": "inference_tokens", + "save_reservation_as": "r1", + "engine": [ + { + "value": true, + "reason": "probe", + "entitlement": { + "value_type": "credit", + "credit_id": "ct_1", + "consumption_rate": 10, + "event_subtype": "inference_tokens" + } + }, + { "value": true, "reason": "ok" } + ], + "expect": { + "allowed": true, + "has_reservation": true, + "reservation": { + "lease_id": "lse_1", + "credit_type_id": "ct_1", + "event_subtype": "inference_tokens", + "quantity_reserved": 10, + "credits_reserved": 100, + "consumption_rate": 10 + }, + "engine_calls": [{ "credit_balance": 5000 }, { "credit_balance": 1000, "credit_cost": 100 }] + } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 900 } + }, + { "op": "reserved_credits", "company_id": "co_1", "credit_type_id": "ct_1", "expect": { "total": 100 } } + ] + }, + { + "name": "check_denied_by_engine_cancels_the_hold", + "description": "When the gate evaluation denies, the reservation made before the eval is cancelled: claimed and fully refunded, leaving no hold and no reservation.", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + }, + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 300000 + } + ] + }, + "operations": [ + { + "op": "check", + "flag_key": "inference", + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + "usage": 10, + "event_subtype": "inference_tokens", + "engine": [ + { + "value": true, + "reason": "probe", + "entitlement": { + "value_type": "credit", + "credit_id": "ct_1", + "consumption_rate": 10, + "event_subtype": "inference_tokens" + } + }, + { "value": false, "reason": "denied_by_targeting" } + ], + "expect": { "allowed": false, "reason": "denied_by_targeting", "has_reservation": false } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 1000 } + }, + { "op": "reserved_credits", "company_id": "co_1", "credit_type_id": "ct_1", "expect": { "total": 0 } }, + { "op": "reservation_count", "expect": { "count": 0 } } + ] + }, + { + "name": "check_acquire_failure_fail_closed_denies", + "description": "fail-closed (the default): when no lease can be acquired the check denies outright with the failure reason; no reservation is issued.", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + } + }, + "operations": [ + { + "op": "check", + "flag_key": "inference", + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + "usage": 10, + "event_subtype": "inference_tokens", + "on_acquire_failure": "fail-closed", + "server": { "acquire": { "error": "wire down" } }, + "engine": [ + { + "value": true, + "reason": "probe", + "entitlement": { + "value_type": "credit", + "credit_id": "ct_1", + "consumption_rate": 10, + "event_subtype": "inference_tokens" + } + } + ], + "expect": { + "allowed": false, + "reason": "lease_acquire_failed", + "err": "lease_acquire_failed", + "has_reservation": false + } + }, + { "op": "reservation_count", "expect": { "count": 0 } } + ] + }, + { + "name": "check_acquire_failure_fail_open_reevaluates", + "description": "fail-open is NOT blanket allow: the engine re-runs with the credit balance substituted to an effectively unlimited value and the caller's usage preflight threaded through, so non-credit rules still apply. Here they pass, so the check allows — with the failure recorded in err and no reservation.", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + } + }, + "operations": [ + { + "op": "check", + "flag_key": "inference", + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + "usage": 10, + "event_subtype": "inference_tokens", + "on_acquire_failure": "fail-open", + "server": { "acquire": { "error": "wire down" } }, + "engine": [ + { + "value": true, + "reason": "probe", + "entitlement": { + "value_type": "credit", + "credit_id": "ct_1", + "consumption_rate": 10, + "event_subtype": "inference_tokens" + } + }, + { "value": true, "reason": "evaluated" } + ], + "expect": { + "allowed": true, + "reason": "evaluated (lease_acquire_failed_fail_open)", + "err": "lease_acquire_failed", + "has_reservation": false, + "engine_calls": [ + { "credit_balance": 5000 }, + { + "credit_balance": "max_safe_integer", + "event_usage": { "event_subtype": "inference_tokens", "quantity": 10 } + } + ] + } + }, + { "op": "reservation_count", "expect": { "count": 0 } } + ] + }, + { + "name": "check_fail_open_still_denies_when_rules_deny", + "description": "fail-open with a denying rules evaluation stays denied: substituting an unlimited balance only bypasses the credit gate, never plan targeting or overrides.", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + } + }, + "operations": [ + { + "op": "check", + "flag_key": "inference", + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + "usage": 10, + "event_subtype": "inference_tokens", + "on_acquire_failure": "fail-open", + "server": { "acquire": { "error": "wire down" } }, + "engine": [ + { + "value": true, + "reason": "probe", + "entitlement": { + "value_type": "credit", + "credit_id": "ct_1", + "consumption_rate": 10, + "event_subtype": "inference_tokens" + } + }, + { "value": false, "reason": "not_targeted" } + ], + "expect": { + "allowed": false, + "reason": "not_targeted (lease_acquire_failed_fail_open)", + "err": "lease_acquire_failed", + "has_reservation": false + } + } + ] + }, + { + "name": "check_insufficient_lease_extends_and_retries", + "description": "A reserve refusal triggers an awaited opportunistic extend sized to cover the request (required_credits = credit_cost), then exactly one reserve retry. On success the check proceeds normally, gating on the post-extend pre-reservation balance.", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + }, + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 300000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 950, + "expect": { "balance": 50 } + }, + { + "op": "check", + "flag_key": "inference", + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + "usage": 10, + "event_subtype": "inference_tokens", + "server": { "extend": { "lease": { "granted_total": 2000, "expires_at_ms": 600000 } } }, + "save_reservation_as": "r1", + "engine": [ + { + "value": true, + "reason": "probe", + "entitlement": { + "value_type": "credit", + "credit_id": "ct_1", + "consumption_rate": 10, + "event_subtype": "inference_tokens" + } + }, + { "value": true, "reason": "ok" } + ], + "expect": { + "allowed": true, + "has_reservation": true, + "wire_extends": 1, + "last_extend_additional_amount": 1000, + "engine_calls": [{ "credit_balance": 5000 }, { "credit_balance": 1050, "credit_cost": 100 }] + } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "granted_amount": 2000, "local_remaining_credits": 950 } + } + ] + }, + { + "name": "check_insufficient_lease_after_failed_extend_resolves_by_mode", + "description": "When the opportunistic extend fails and the retry is still refused, the check resolves through the failure mode (fail-closed here) with reason insufficient_lease_balance, leaving the lease balance untouched.", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + }, + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 300000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 950, + "expect": { "balance": 50 } + }, + { + "op": "check", + "flag_key": "inference", + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + "usage": 10, + "event_subtype": "inference_tokens", + "on_acquire_failure": "fail-closed", + "server": { "extend": { "error": "wire down" } }, + "engine": [ + { + "value": true, + "reason": "probe", + "entitlement": { + "value_type": "credit", + "credit_id": "ct_1", + "consumption_rate": 10, + "event_subtype": "inference_tokens" + } + } + ], + "expect": { + "allowed": false, + "reason": "insufficient_lease_balance", + "err": "insufficient_lease_balance", + "has_reservation": false + } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 50 } + }, + { "op": "reservation_count", "expect": { "count": 0 } } + ] + }, + { + "name": "check_falls_back_without_usable_credit_entitlement", + "description": "A non-credit matched entitlement (boolean/override/numeric/unlimited/not entitled) or an incomplete credit entitlement (no positive consumption rate) defers to the plain check: no lease traffic, no reservation.", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + } + }, + "operations": [ + { + "op": "check", + "flag_key": "inference", + "company": { "id": "co_1", "credit_balances": {} }, + "usage": 10, + "event_subtype": "inference_tokens", + "engine": [{ "value": true, "reason": "probe", "entitlement": { "value_type": "boolean" } }], + "expect": { "fallback_called": true, "reason": "fallback", "has_reservation": false } + }, + { + "op": "check", + "flag_key": "inference", + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + "usage": 10, + "event_subtype": "inference_tokens", + "engine": [ + { + "value": true, + "reason": "probe", + "entitlement": { + "value_type": "credit", + "credit_id": "ct_1", + "consumption_rate": 0, + "event_subtype": "inference_tokens" + } + } + ], + "expect": { "fallback_called": true, "reason": "fallback", "has_reservation": false } + }, + { "op": "reservation_count", "expect": { "count": 0 } } + ] + }, + { + "name": "check_zero_usage_falls_back", + "description": "usage = 0 means nothing to reserve: the check defers to the plain (preflight-threaded) check instead of issuing a no-op 0-credit reservation.", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + }, + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 300000 + } + ] + }, + "operations": [ + { + "op": "check", + "flag_key": "inference", + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + "usage": 0, + "event_subtype": "inference_tokens", + "engine": [], + "expect": { "fallback_called": true, "reason": "fallback", "has_reservation": false } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 1000 } + } + ] + }, + { + "name": "check_invalid_usage_resolves_statically_by_mode", + "description": "A negative (or non-finite) usage must never reach the stores; the check resolves statically by mode without any engine evaluation: deny for fail-closed, blanket allow for fail-open, reason invalid_usage either way.", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + }, + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 300000 + } + ] + }, + "operations": [ + { + "op": "check", + "flag_key": "inference", + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + "usage": -5, + "event_subtype": "inference_tokens", + "on_acquire_failure": "fail-closed", + "engine": [], + "expect": { + "allowed": false, + "reason": "invalid_usage", + "err": "invalid_usage", + "has_reservation": false, + "engine_calls": [] + } + }, + { + "op": "check", + "flag_key": "inference", + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + "usage": -5, + "event_subtype": "inference_tokens", + "on_acquire_failure": "fail-open", + "engine": [], + "expect": { + "allowed": true, + "reason": "invalid_usage_fail_open", + "err": "invalid_usage", + "has_reservation": false, + "engine_calls": [] + } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 1000 } + } + ] + } + ] + } + conformance/vectors/crash-windows.json: | + { + "category": "crash_window", + "vectors": [ + { + "name": "debit_without_record_leaks_bounded", + "description": "Crash window 1 (debit-then-add): the atomic debit landed but the reservation record never did. The leak is exactly the reserved amount; the sweeper can never refund a hold that was never recorded.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 100, + "expect": { "balance": 900 } + }, + { "op": "reserved_credits", "company_id": "co_1", "credit_type_id": "ct_1", "expect": { "total": 0 } }, + { "op": "advance_clock", "ms": 55000 }, + { "op": "sweep_expired", "expect": { "swept": 0 } }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 900 } + } + ] + }, + { + "name": "debit_leak_reclaimed_at_lease_expiry", + "description": "Crash window 1 recovery: the leaked balance is never served after lease expiry, and the successor lease installs at the full grant — the leak does not outlive the lease.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 100, + "expect": { "balance": 900 } + }, + { "op": "advance_clock", "ms": 60001 }, + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 1, + "expect": { "balance": null } + }, + { + "op": "replace_lease", + "lease_id": "lse_2", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 180000, + "expect": { "written": true } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 1000 } + } + ] + }, + { + "name": "retried_check_after_debit_leak_settles_once", + "description": "A retry after a window-1 crash is a fresh check with a fresh reservation: its unspent slice refunds exactly once; the leaked slice never refunds — not on a repeat consume, not on a sweep.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 3600000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 100, + "expect": { "balance": 900 } + }, + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 100, + "expect": { "balance": 800 } + }, + { + "op": "add_reservation", + "id": "res_retry", + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "event_subtype": "inference_tokens", + "quantity_reserved": 10, + "credits_reserved": 100, + "consumption_rate": 10, + "expires_at_ms": 60000 + }, + { "op": "consume_reservation", "id": "res_retry", "credits": 40, "expect": { "consumed": 40 } }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 860 } + }, + { "op": "consume_reservation", "id": "res_retry", "credits": 40, "expect": { "consumed": null } }, + { "op": "advance_clock", "ms": 70000 }, + { "op": "sweep_expired", "expect": { "swept": 0 } }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 860 } + } + ] + }, + { + "name": "crash_before_refund_claim_is_durable", + "description": "Crash window 2 (consume-then-refund): the claim survives the crash, so the reservation is gone everywhere and nothing can double-spend; the unspent slice's refund is lost, bounded by credits_reserved. A retried settle neither re-claims nor double-refunds, and the sweeper cannot refund a claimed reservation.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 3600000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 100, + "expect": { "balance": 900 } + }, + { + "op": "add_reservation", + "id": "res_1", + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "event_subtype": "inference_tokens", + "quantity_reserved": 10, + "credits_reserved": 100, + "consumption_rate": 10, + "expires_at_ms": 60000 + }, + { + "op": "consume_reservation", + "id": "res_1", + "credits": 30, + "crash_before_refund": true, + "expect": { "throws": true } + }, + { "op": "get_reservation", "id": "res_1", "expect": { "exists": false } }, + { "op": "reserved_credits", "company_id": "co_1", "credit_type_id": "ct_1", "expect": { "total": 0 } }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 900 } + }, + { "op": "consume_reservation", "id": "res_1", "credits": 30, "expect": { "consumed": null } }, + { "op": "advance_clock", "ms": 70000 }, + { "op": "sweep_expired", "expect": { "swept": 0 } }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 900 } + } + ] + }, + { + "name": "crash_before_refund_reclaimed_at_lease_expiry", + "description": "Crash window 2 recovery: after the lease expires and a successor takes the slot at full grant, a very late retried settle of the crashed reservation must not leak the lost refund into the successor.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 100, + "expect": { "balance": 900 } + }, + { + "op": "add_reservation", + "id": "res_1", + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "event_subtype": "inference_tokens", + "quantity_reserved": 10, + "credits_reserved": 100, + "consumption_rate": 10, + "expires_at_ms": 60000 + }, + { + "op": "consume_reservation", + "id": "res_1", + "credits": 30, + "crash_before_refund": true, + "expect": { "throws": true } + }, + { "op": "advance_clock", "ms": 60001 }, + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 1, + "expect": { "balance": null } + }, + { + "op": "replace_lease", + "lease_id": "lse_2", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 180000, + "expect": { "written": true } + }, + { "op": "consume_reservation", "id": "res_1", "credits": 0, "expect": { "consumed": null } }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "lease_id": "lse_2", "local_remaining_credits": 1000 } + } + ] + } + ] + } + conformance/vectors/expiry.json: | + { + "category": "expiry", + "vectors": [ + { + "name": "expired_lease_never_serves_reserves", + "description": "Past its expiry a lease's balance is stale (the server refunded the grant): reserves are refused even with ample local balance.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 100, + "expect": { "balance": 900 } + }, + { "op": "advance_clock", "ms": 60001 }, + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 1, + "expect": { "balance": null } + } + ] + }, + { + "name": "successor_after_expiry_restores_full_grant", + "description": "A successor lease installed over an expired slot starts at its full grant — nothing from the expired lease (debits or leaks) carries over.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 700, + "expect": { "balance": 300 } + }, + { "op": "advance_clock", "ms": 60001 }, + { + "op": "replace_lease", + "lease_id": "lse_2", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 180000, + "expect": { "written": true } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "lease_id": "lse_2", "local_remaining_credits": 1000 } + } + ] + }, + { + "name": "sweep_refunds_expired_holds_only", + "description": "The sweeper refunds an expired reservation's full hold to its lease and leaves live reservations untouched.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 300000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 300, + "expect": { "balance": 700 } + }, + { + "op": "add_reservation", + "id": "res_short", + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "event_subtype": "inference_tokens", + "quantity_reserved": 10, + "credits_reserved": 100, + "consumption_rate": 10, + "expires_at_ms": 10000 + }, + { + "op": "add_reservation", + "id": "res_long", + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "event_subtype": "inference_tokens", + "quantity_reserved": 20, + "credits_reserved": 200, + "consumption_rate": 10, + "expires_at_ms": 200000 + }, + { "op": "sweep_expired", "expect": { "swept": 0 } }, + { "op": "advance_clock", "ms": 10001 }, + { "op": "sweep_expired", "expect": { "swept": 1 } }, + { "op": "get_reservation", "id": "res_short", "expect": { "exists": false } }, + { "op": "get_reservation", "id": "res_long", "expect": { "exists": true } }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 800 } + }, + { "op": "reserved_credits", "company_id": "co_1", "credit_type_id": "ct_1", "expect": { "total": 200 } } + ] + }, + { + "name": "sweep_of_stale_lease_hold_does_not_inflate_successor", + "description": "Sweeping (or consuming) a reservation carved from an expired lease refunds nothing into the successor lease occupying the slot: the hold is pinned to its originating lease_id.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 100, + "expect": { "balance": 900 } + }, + { + "op": "add_reservation", + "id": "res_stale", + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "event_subtype": "inference_tokens", + "quantity_reserved": 10, + "credits_reserved": 100, + "consumption_rate": 10, + "expires_at_ms": 90000 + }, + { "op": "advance_clock", "ms": 60001 }, + { + "op": "replace_lease", + "lease_id": "lse_2", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 300000, + "expect": { "written": true } + }, + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 200, + "expect": { "balance": 800 } + }, + { "op": "advance_clock", "ms": 30000 }, + { "op": "sweep_expired", "expect": { "swept": 1 } }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "lease_id": "lse_2", "local_remaining_credits": 800 } + } + ] + } + ] + } + conformance/vectors/lease-lifecycle.json: | + { + "category": "lease_lifecycle", + "vectors": [ + { + "name": "replace_installs_full_grant", + "description": "A fresh install initializes local_remaining_credits to the full granted amount.", + "operations": [ + { + "op": "replace_lease", + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 60000, + "expect": { "written": true } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { + "exists": true, + "lease_id": "lse_1", + "granted_amount": 1000, + "local_remaining_credits": 1000 + } + } + ] + }, + { + "name": "replace_keeps_live_lease_even_with_different_id", + "description": "A live lease occupying the slot wins over any replace — even one carrying a different lease_id (a sibling raced this acquire). Its already-debited balance is preserved.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 400, + "expect": { "balance": 600 } + }, + { + "op": "replace_lease", + "lease_id": "lse_2", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 5000, + "expires_at_ms": 120000, + "expect": { "written": false } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { + "exists": true, + "lease_id": "lse_1", + "granted_amount": 1000, + "local_remaining_credits": 600 + } + } + ] + }, + { + "name": "replace_reconciles_expired_slot_with_same_id", + "description": "A stale acquire response for the SAME lease landing over its own expired local row must not reinstall it: that would reset local_remaining_credits and erase debits whose reservations are still open. The row is reconciled like an extend (granted to total, expiry forward, balance untouched) and reported as kept.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 400, + "expect": { "balance": 600 } + }, + { "op": "advance_clock", "ms": 60001 }, + { + "op": "replace_lease", + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1500, + "expires_at_ms": 120000, + "expect": { "written": false } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { + "exists": true, + "lease_id": "lse_1", + "granted_amount": 1500, + "local_remaining_credits": 1100 + } + }, + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 100, + "expect": { "balance": 1000 } + } + ] + }, + { + "name": "replace_overwrites_expired_lease", + "description": "An expired lease does not block the slot: replace overwrites it atomically and restores the full new grant.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 400, + "expect": { "balance": 600 } + }, + { "op": "advance_clock", "ms": 60001 }, + { + "op": "replace_lease", + "lease_id": "lse_2", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 180000, + "expect": { "written": true } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "lease_id": "lse_2", "local_remaining_credits": 1000 } + } + ] + }, + { + "name": "try_reserve_insufficient_and_boundary", + "description": "A reserve larger than the remaining balance is refused and touches nothing; reserving down to exactly zero is allowed; negative amounts are always refused.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 100, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 101, + "expect": { "balance": null } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 100 } + }, + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": -1, + "expect": { "balance": null } + }, + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 100, + "expect": { "balance": 0 } + }, + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 1, + "expect": { "balance": null } + }, + { + "op": "try_reserve", + "company_id": "co_9", + "credit_type_id": "ct_1", + "credits": 1, + "expect": { "balance": null } + } + ] + }, + { + "name": "refund_clamped_at_granted_amount", + "description": "A refund can never push the local balance above the granted amount.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 100, + "expect": { "balance": 900 } + }, + { "op": "refund_lease", "company_id": "co_1", "credit_type_id": "ct_1", "credits": 500 }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 1000 } + } + ] + }, + { + "name": "refund_pinned_to_lease_id_dropped_on_successor", + "description": "A refund pinned to an expired lease's id must not inflate the successor lease occupying the slot — the expired lease's remainder was already returned to the company balance server-side. An unpinned refund still applies.", + "given": { + "leases": [ + { + "lease_id": "lse_2", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 200, + "expect": { "balance": 800 } + }, + { + "op": "refund_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 100, + "pin_lease_id": "lse_1" + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 800 } + }, + { + "op": "refund_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 100, + "pin_lease_id": "lse_2" + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 900 } + } + ] + }, + { + "name": "extend_reconciles_to_total_and_converges", + "description": "Extend applies the server-authoritative TOTAL: the delta is computed against the stored total, so a repeated or stale-lower total is a no-op and out-of-order applies converge without minting credits.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 300000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 800, + "expect": { "balance": 200 } + }, + { + "op": "extend_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_total": 3000, + "expires_at_ms": 300000 + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "granted_amount": 3000, "local_remaining_credits": 2200 } + }, + { + "op": "extend_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_total": 3000, + "expires_at_ms": 300000 + }, + { + "op": "extend_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_total": 2000, + "expires_at_ms": 300000 + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "granted_amount": 3000, "local_remaining_credits": 2200 } + } + ] + }, + { + "name": "extend_expiry_only_moves_forward", + "description": "An extend carrying an earlier expiry must not shorten the lease: after an extend to a later expiry, a stale out-of-order apply with an earlier expiry leaves the lease live past that earlier instant.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { + "op": "extend_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_total": 2000, + "expires_at_ms": 120000 + }, + { + "op": "extend_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_total": 2000, + "expires_at_ms": 30000 + }, + { "op": "advance_clock", "ms": 90000 }, + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 100, + "expect": { "balance": 1900 } + } + ] + }, + { + "name": "extend_pinned_to_lease_id_dropped_on_successor", + "description": "An extend pinned to a lease the slot no longer holds is dropped entirely — crediting the successor would mint credits the server granted to the expired lease.", + "given": { + "leases": [ + { + "lease_id": "lse_2", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 300000 + } + ] + }, + "operations": [ + { + "op": "extend_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_total": 5000, + "expires_at_ms": 600000, + "pin_lease_id": "lse_1" + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "granted_amount": 1000, "local_remaining_credits": 1000 } + }, + { + "op": "extend_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_total": 2000, + "expires_at_ms": 600000, + "pin_lease_id": "lse_2" + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "granted_amount": 2000, "local_remaining_credits": 2000 } + } + ] + } + ] + } + conformance/vectors/lease-manager.json: | + { + "category": "lease_manager", + "vectors": [ + { + "name": "acquire_installs_tranche_and_reuses_live_lease", + "description": "First acquire requests lease_size from the server and installs the response at full grant; a second acquire while the lease is live makes no wire call.", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + } + }, + "operations": [ + { + "op": "acquire_if_needed", + "company_id": "co_1", + "credit_type_id": "ct_1", + "server": { + "lease": { "lease_id": "lse_1", "granted_amount": 1000, "expires_at_ms": 300000 } + }, + "expect": { + "lease_id": "lse_1", + "wire_acquires": 1, + "last_acquire_requested_amount": 1000, + "released_lease_ids": [] + } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "lease_id": "lse_1", "local_remaining_credits": 1000 } + }, + { + "op": "acquire_if_needed", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "lease_id": "lse_1", "wire_acquires": 1 } + } + ] + }, + { + "name": "acquire_replaces_expired_slot_without_release", + "description": "An expired slot triggers a fresh acquire that supplants the stale entry in place; the redundant-lease release path must not fire (replace wrote, it did not keep a live lease).", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + }, + "leases": [ + { + "lease_id": "lse_stale", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { "op": "advance_clock", "ms": 60001 }, + { + "op": "acquire_if_needed", + "company_id": "co_1", + "credit_type_id": "ct_1", + "server": { + "lease": { "lease_id": "lse_fresh", "granted_amount": 1000, "expires_at_ms": 360000 } + }, + "expect": { "lease_id": "lse_fresh", "wire_acquires": 1, "released_lease_ids": [] } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "lease_id": "lse_fresh", "local_remaining_credits": 1000 } + } + ] + }, + { + "name": "lost_acquire_race_different_id_releases_redundant_lease", + "description": "A sibling installs a live lease while this acquire's wire call is in flight. The installed lease (with its debited balance) wins; the lease the server minted for the loser is redundant and gets released so it is not orphaned against the company balance.", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + } + }, + "operations": [ + { + "op": "acquire_if_needed", + "company_id": "co_1", + "credit_type_id": "ct_1", + "server": { + "lease": { "lease_id": "lse_loser", "granted_amount": 1000, "expires_at_ms": 300000 } + }, + "install_during_wire": { + "lease_id": "lse_winner", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 300000 + }, + "expect": { "lease_id": "lse_winner", "wire_acquires": 1, "released_lease_ids": ["lse_loser"] } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "lease_id": "lse_winner" } + } + ] + }, + { + "name": "lost_acquire_race_same_id_releases_nothing", + "description": "The server is idempotent for an active slot: a racing acquire is handed back the SAME lease the sibling installed. There is nothing to release — releasing would pull the shared lease out from under every sibling.", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + } + }, + "operations": [ + { + "op": "acquire_if_needed", + "company_id": "co_1", + "credit_type_id": "ct_1", + "server": { + "lease": { "lease_id": "lse_shared", "granted_amount": 1000, "expires_at_ms": 300000 } + }, + "install_during_wire": { + "lease_id": "lse_shared", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 300000 + }, + "expect": { "lease_id": "lse_shared", "wire_acquires": 1, "released_lease_ids": [] } + } + ] + }, + { + "name": "extend_triggered_at_low_water_mark_requests_tranche", + "description": "At or below the low-water-mark ratio a steady-state extend fires, requesting the configured tranche (lease_size) and reconciling the local row to the server total.", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + }, + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 300000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 800, + "expect": { "balance": 200 } + }, + { + "op": "maybe_extend", + "company_id": "co_1", + "credit_type_id": "ct_1", + "server": { "lease": { "granted_total": 2000, "expires_at_ms": 600000 } }, + "expect": { + "wire_extends": 1, + "last_extend_additional_amount": 1000, + "last_extend_lease_id": "lse_1" + } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "granted_amount": 2000, "local_remaining_credits": 1200 } + } + ] + }, + { + "name": "extend_triggered_by_required_credits_above_watermark", + "description": "Above the watermark no steady-state extend fires; a required_credits hint larger than the local remaining triggers one anyway (a check just failed a reserve of that size).", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + }, + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 300000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 100, + "expect": { "balance": 900 } + }, + { + "op": "maybe_extend", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "wire_extends": 0 } + }, + { + "op": "maybe_extend", + "company_id": "co_1", + "credit_type_id": "ct_1", + "required_credits": 1500, + "server": { "lease": { "granted_total": 2000, "expires_at_ms": 600000 } }, + "expect": { "wire_extends": 1, "last_extend_additional_amount": 1000 } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "granted_amount": 2000, "local_remaining_credits": 1900 } + } + ] + }, + { + "name": "extend_sized_to_shortfall_when_larger_than_tranche", + "description": "additional_amount = max(lease_size, required_credits - local_remaining): a single request larger than remaining + tranche must extend by the shortfall, or its post-extend retry would fail forever regardless of server balance.", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + }, + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 300000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 100, + "expect": { "balance": 900 } + }, + { + "op": "maybe_extend", + "company_id": "co_1", + "credit_type_id": "ct_1", + "required_credits": 5000, + "server": { "lease": { "granted_total": 5100, "expires_at_ms": 600000 } }, + "expect": { "wire_extends": 1, "last_extend_additional_amount": 4100 } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "granted_amount": 5100, "local_remaining_credits": 5000 } + } + ] + }, + { + "name": "never_extend_an_expired_lease", + "description": "An expired lease is released as far as the server is concerned — the only correct move is a fresh acquire, never an extend, no matter how depleted the balance.", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + }, + "leases": [ + { + "lease_id": "lse_old", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 900, + "expect": { "balance": 100 } + }, + { "op": "advance_clock", "ms": 60001 }, + { + "op": "maybe_extend", + "company_id": "co_1", + "credit_type_id": "ct_1", + "required_credits": 1500, + "expect": { "wire_extends": 0 } + } + ] + }, + { + "name": "wire_failures_resolve_to_no_lease_without_state_changes", + "description": "A failed acquire yields no lease and installs nothing; a failed extend leaves the local row untouched. Neither throws (both are routed through fail-open/fail-closed by callers).", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + } + }, + "operations": [ + { + "op": "acquire_if_needed", + "company_id": "co_1", + "credit_type_id": "ct_1", + "server": { "error": "wire down" }, + "expect": { "lease_id": null, "wire_acquires": 1 } + }, + { "op": "get_lease", "company_id": "co_1", "credit_type_id": "ct_1", "expect": { "exists": false } }, + { + "op": "replace_lease", + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 300000, + "expect": { "written": true } + }, + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 800, + "expect": { "balance": 200 } + }, + { + "op": "maybe_extend", + "company_id": "co_1", + "credit_type_id": "ct_1", + "server": { "error": "wire down" }, + "expect": { "wire_extends": 1 } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "granted_amount": 1000, "local_remaining_credits": 200 } + } + ] + }, + { + "name": "release_all_releases_live_and_skips_expired", + "description": "On close, a per-process store releases its live leases over the wire (returning remainders immediately) and drops them locally; expired leases are skipped — the server already swept them. Only valid for an exclusively-owned (in-memory) store; a shared backend must never enumerate-and-release.", + "backends": ["in_memory"], + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + }, + "leases": [ + { + "lease_id": "lse_live", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 300000 + }, + { + "lease_id": "lse_expired", + "company_id": "co_2", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 30000 + } + ] + }, + "operations": [ + { "op": "advance_clock", "ms": 30001 }, + { + "op": "release_all_local_leases", + "expect": { "released_lease_ids": ["lse_live"] } + }, + { "op": "get_lease", "company_id": "co_1", "credit_type_id": "ct_1", "expect": { "exists": false } } + ] + } + ] + } + conformance/vectors/reservation-lifecycle.json: | + { + "category": "reservation_lifecycle", + "vectors": [ + { + "name": "consume_exact_usage_no_refund", + "description": "Consuming exactly the reserved amount removes the reservation and refunds nothing.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 100, + "expect": { "balance": 900 } + }, + { + "op": "add_reservation", + "id": "res_1", + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "event_subtype": "inference_tokens", + "quantity_reserved": 10, + "credits_reserved": 100, + "consumption_rate": 10, + "expires_at_ms": 60000 + }, + { "op": "consume_reservation", "id": "res_1", "credits": 100, "expect": { "consumed": 100 } }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 900 } + }, + { "op": "get_reservation", "id": "res_1", "expect": { "exists": false } } + ] + }, + { + "name": "consume_under_reserved_refunds_unspent", + "description": "Consuming less than reserved refunds the unspent slice to the lease in the same step.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 100, + "expect": { "balance": 900 } + }, + { + "op": "add_reservation", + "id": "res_1", + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "event_subtype": "inference_tokens", + "quantity_reserved": 10, + "credits_reserved": 100, + "consumption_rate": 10, + "expires_at_ms": 60000 + }, + { "op": "consume_reservation", "id": "res_1", "credits": 30, "expect": { "consumed": 30 } }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 970 } + } + ] + }, + { + "name": "consume_over_reserved_clamps_to_hold", + "description": "Local consumption is clamped to credits_reserved: over-use consumes the full hold, refunds nothing, and never debits the lease beyond the reservation. (The billed Track quantity is NOT clamped — see track-settle vectors.)", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 100, + "expect": { "balance": 900 } + }, + { + "op": "add_reservation", + "id": "res_1", + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "event_subtype": "inference_tokens", + "quantity_reserved": 10, + "credits_reserved": 100, + "consumption_rate": 10, + "expires_at_ms": 60000 + }, + { "op": "consume_reservation", "id": "res_1", "credits": 999, "expect": { "consumed": 100 } }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 900 } + } + ] + }, + { + "name": "consume_zero_cancels_with_full_refund", + "description": "Consuming 0 credits acts as a cancel: the full hold is refunded. Negative consumption clamps to 0 the same way.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 100, + "expect": { "balance": 900 } + }, + { + "op": "add_reservation", + "id": "res_1", + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "event_subtype": "inference_tokens", + "quantity_reserved": 10, + "credits_reserved": 100, + "consumption_rate": 10, + "expires_at_ms": 60000 + }, + { "op": "consume_reservation", "id": "res_1", "credits": 0, "expect": { "consumed": 0 } }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 1000 } + } + ] + }, + { + "name": "consume_is_exactly_once", + "description": "A missing reservation and a second consume of the same id both return null and refund nothing — the claim is the exactly-once arbiter.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { "op": "consume_reservation", "id": "res_missing", "credits": 10, "expect": { "consumed": null } }, + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 100, + "expect": { "balance": 900 } + }, + { + "op": "add_reservation", + "id": "res_1", + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "event_subtype": "inference_tokens", + "quantity_reserved": 10, + "credits_reserved": 100, + "consumption_rate": 10, + "expires_at_ms": 60000 + }, + { "op": "consume_reservation", "id": "res_1", "credits": 30, "expect": { "consumed": 30 } }, + { "op": "consume_reservation", "id": "res_1", "credits": 30, "expect": { "consumed": null } }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 970 } + } + ] + }, + { + "name": "reserved_credits_sums_open_holds_per_slot", + "description": "reserved_credits sums credits_reserved across open reservations for the exact (company, credit) slot only, and a hold stops counting the moment it is consumed.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 350, + "expect": { "balance": 650 } + }, + { + "op": "add_reservation", + "id": "res_1", + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "event_subtype": "inference_tokens", + "quantity_reserved": 10, + "credits_reserved": 100, + "consumption_rate": 10, + "expires_at_ms": 60000 + }, + { + "op": "add_reservation", + "id": "res_2", + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "event_subtype": "inference_tokens", + "quantity_reserved": 25, + "credits_reserved": 250, + "consumption_rate": 10, + "expires_at_ms": 60000 + }, + { + "op": "add_reservation", + "id": "res_other_credit", + "lease_id": "lse_9", + "company_id": "co_1", + "credit_type_id": "ct_2", + "event_subtype": "inference_tokens", + "quantity_reserved": 99, + "credits_reserved": 999, + "consumption_rate": 10, + "expires_at_ms": 60000 + }, + { + "op": "add_reservation", + "id": "res_other_company", + "lease_id": "lse_8", + "company_id": "co_2", + "credit_type_id": "ct_1", + "event_subtype": "inference_tokens", + "quantity_reserved": 99, + "credits_reserved": 999, + "consumption_rate": 10, + "expires_at_ms": 60000 + }, + { + "op": "reserved_credits", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "total": 350 } + }, + { + "op": "reserved_credits", + "company_id": "co_1", + "credit_type_id": "ct_2", + "expect": { "total": 999 } + }, + { "op": "reserved_credits", "company_id": "co_9", "credit_type_id": "ct_1", "expect": { "total": 0 } }, + { "op": "consume_reservation", "id": "res_1", "credits": 40, "expect": { "consumed": 40 } }, + { "op": "reserved_credits", "company_id": "co_1", "credit_type_id": "ct_1", "expect": { "total": 250 } } + ] + }, + { + "name": "fractional_credit_amounts_are_exact", + "description": "Fractional consumption rates produce fractional holds; reserve, consume, and refund arithmetic must not truncate.", + "given": { + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 10, + "expires_at_ms": 60000 + } + ] + }, + "operations": [ + { + "op": "try_reserve", + "company_id": "co_1", + "credit_type_id": "ct_1", + "credits": 2.5, + "expect": { "balance": 7.5 } + }, + { + "op": "add_reservation", + "id": "res_1", + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "event_subtype": "inference_tokens", + "quantity_reserved": 25, + "credits_reserved": 2.5, + "consumption_rate": 0.1, + "expires_at_ms": 60000 + }, + { "op": "consume_reservation", "id": "res_1", "credits": 1.5, "expect": { "consumed": 1.5 } }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 8.5 } + } + ] + } + ] + } + conformance/vectors/track-settle.json: | + { + "category": "track_settle", + "vectors": [ + { + "name": "track_underuse_settles_and_refunds_unspent", + "description": "Settling with less than the reserved usage consumes actual x rate, refunds the unspent slice to the lease, and emits a Track billing the ACTUAL quantity keyed to the lease.", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + }, + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 300000 + } + ] + }, + "operations": [ + { + "op": "check", + "flag_key": "inference", + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + "usage": 10, + "event_subtype": "inference_tokens", + "save_reservation_as": "r1", + "engine": [ + { + "value": true, + "reason": "probe", + "entitlement": { + "value_type": "credit", + "credit_id": "ct_1", + "consumption_rate": 10, + "event_subtype": "inference_tokens" + } + }, + { "value": true, "reason": "ok" } + ], + "expect": { "allowed": true, "has_reservation": true } + }, + { + "op": "track", + "handle": "r1", + "actual_quantity": 4, + "expect": { + "settled_locally": true, + "track": { "event": "inference_tokens", "quantity": 4, "lease_id": "lse_1" } + } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 960 } + }, + { "op": "reserved_credits", "company_id": "co_1", "credit_type_id": "ct_1", "expect": { "total": 0 } } + ] + }, + { + "name": "track_overuse_bills_actual_but_clamps_local_debit", + "description": "Actual usage above the reservation: the LOCAL settle clamps consumption to the reserved hold (the lease is never debited past the reservation), but the Track event bills the unclamped actual quantity — the server is the source of truth for real consumption.", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + }, + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 300000 + } + ] + }, + "operations": [ + { + "op": "check", + "flag_key": "inference", + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + "usage": 10, + "event_subtype": "inference_tokens", + "save_reservation_as": "r1", + "engine": [ + { + "value": true, + "reason": "probe", + "entitlement": { + "value_type": "credit", + "credit_id": "ct_1", + "consumption_rate": 10, + "event_subtype": "inference_tokens" + } + }, + { "value": true, "reason": "ok" } + ], + "expect": { "allowed": true, "has_reservation": true } + }, + { + "op": "track", + "handle": "r1", + "actual_quantity": 25, + "expect": { + "settled_locally": true, + "track": { "event": "inference_tokens", "quantity": 25, "lease_id": "lse_1" } + } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 900 } + } + ] + }, + { + "name": "track_after_sweep_is_a_recovery_emit", + "description": "Work outliving the reservation TTL: the sweeper already refunded the full hold, so the late settle does not touch the lease (the local balance reads high until rollover) — but the Track is still emitted so the server bills the actual usage. Server-side idempotency (a deterministic key derived from the reservation id) is what keeps a racing normal emit from double-billing.", + "given": { + "config": { + "lease_duration_ms": 300000, + "reservation_ttl_ms": 60000, + "lease_size": 1000, + "low_water_mark": 0.25 + }, + "leases": [ + { + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "granted_amount": 1000, + "expires_at_ms": 300000 + } + ] + }, + "operations": [ + { + "op": "check", + "flag_key": "inference", + "company": { "id": "co_1", "credit_balances": { "ct_1": 5000 } }, + "usage": 10, + "event_subtype": "inference_tokens", + "save_reservation_as": "r1", + "engine": [ + { + "value": true, + "reason": "probe", + "entitlement": { + "value_type": "credit", + "credit_id": "ct_1", + "consumption_rate": 10, + "event_subtype": "inference_tokens" + } + }, + { "value": true, "reason": "ok" } + ], + "expect": { "allowed": true, "has_reservation": true } + }, + { "op": "advance_clock", "ms": 60001 }, + { "op": "sweep_expired", "expect": { "swept": 1 } }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 1000 } + }, + { + "op": "track", + "handle": "r1", + "actual_quantity": 4, + "expect": { + "settled_locally": false, + "track": { "event": "inference_tokens", "quantity": 4, "lease_id": "lse_1" } + } + }, + { + "op": "get_lease", + "company_id": "co_1", + "credit_type_id": "ct_1", + "expect": { "exists": true, "local_remaining_credits": 1000 } + } + ] + } + ] + } + poetry.lock: | + # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + + [[package]] + name = "aiohappyeyeballs" + version = "2.7.1" + description = "Happy Eyeballs for asyncio" + optional = false + python-versions = ">=3.10" + files = [ + {file = "aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472"}, + {file = "aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d"}, + ] + + [[package]] + name = "aiohttp" + version = "3.14.3" + description = "Async http client/server framework (asyncio)" + optional = false + python-versions = ">=3.10" + files = [ + {file = "aiohttp-3.14.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b"}, + {file = "aiohttp-3.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a"}, + {file = "aiohttp-3.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5"}, + {file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f"}, + {file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43"}, + {file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9"}, + {file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8"}, + {file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479"}, + {file = "aiohttp-3.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b"}, + {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d"}, + {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d"}, + {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2"}, + {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48"}, + {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f"}, + {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32"}, + {file = "aiohttp-3.14.3-cp310-cp310-win32.whl", hash = "sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e"}, + {file = "aiohttp-3.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c"}, + {file = "aiohttp-3.14.3-cp310-cp310-win_arm64.whl", hash = "sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb"}, + {file = "aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3"}, + {file = "aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a"}, + {file = "aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8"}, + {file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239"}, + {file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f"}, + {file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06"}, + {file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929"}, + {file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db"}, + {file = "aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce"}, + {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c"}, + {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15"}, + {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c"}, + {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae"}, + {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910"}, + {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7"}, + {file = "aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa"}, + {file = "aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d"}, + {file = "aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39"}, + {file = "aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5"}, + {file = "aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228"}, + {file = "aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee"}, + {file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a"}, + {file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b"}, + {file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529"}, + {file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787"}, + {file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42"}, + {file = "aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b"}, + {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043"}, + {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427"}, + {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d"}, + {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0"}, + {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d"}, + {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19"}, + {file = "aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559"}, + {file = "aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a"}, + {file = "aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c"}, + {file = "aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86"}, + {file = "aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627"}, + {file = "aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82"}, + {file = "aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c"}, + {file = "aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f"}, + {file = "aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80"}, + {file = "aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0"}, + {file = "aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf"}, + {file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd"}, + {file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807"}, + {file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8"}, + {file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24"}, + {file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5"}, + {file = "aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4"}, + {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9"}, + {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1"}, + {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371"}, + {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde"}, + {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e"}, + {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71"}, + {file = "aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0"}, + {file = "aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883"}, + {file = "aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2"}, + {file = "aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062"}, + {file = "aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6"}, + {file = "aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919"}, + {file = "aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7"}, + {file = "aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0"}, + {file = "aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924"}, + {file = "aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646"}, + {file = "aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b"}, + {file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30"}, + {file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9"}, + {file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f"}, + {file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d"}, + {file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147"}, + {file = "aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c"}, + {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a"}, + {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0"}, + {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661"}, + {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22"}, + {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41"}, + {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf"}, + {file = "aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da"}, + {file = "aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100"}, + {file = "aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc"}, + {file = "aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b"}, + {file = "aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0"}, + {file = "aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e"}, + {file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716"}, + {file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f"}, + {file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553"}, + {file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100"}, + {file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85"}, + {file = "aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33"}, + {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f"}, + {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0"}, + {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098"}, + {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25"}, + {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9"}, + {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb"}, + {file = "aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963"}, + {file = "aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b"}, + {file = "aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7"}, + {file = "aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc"}, + ] + + [package.dependencies] + aiohappyeyeballs = ">=2.5.0" + aiosignal = ">=1.4.0" + async-timeout = {version = ">=4.0,<6.0", markers = "python_version < \"3.11\""} + attrs = ">=17.3.0" + frozenlist = ">=1.1.1" + multidict = ">=4.5,<7.0" + propcache = ">=0.2.0" + typing_extensions = {version = ">=4.4", markers = "python_version < \"3.13\""} + yarl = ">=1.17.0,<2.0" + + [package.extras] + speedups = ["Brotli (>=1.2)", "aiodns (>=3.3.0)", "backports.zstd", "brotlicffi (>=1.2)"] + + [[package]] + name = "aiosignal" + version = "1.4.0" + description = "aiosignal: a list of registered asynchronous callbacks" + optional = false + python-versions = ">=3.9" + files = [ + {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, + {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, + ] + + [package.dependencies] + frozenlist = ">=1.1.0" + typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} + + [[package]] + name = "annotated-types" + version = "0.8.0" + description = "Reusable constraint types to use with typing.Annotated" + optional = false + python-versions = ">=3.10" + files = [ + {file = "annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0"}, + {file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"}, + ] + + [[package]] + name = "anyio" + version = "4.15.0" + description = "High-level concurrency and networking framework on top of asyncio or Trio" + optional = false + python-versions = ">=3.10" + files = [ + {file = "anyio-4.15.0-py3-none-any.whl", hash = "sha256:7ecd9937369ffce8bba0b5ccb9b3a9507b101b0ed50256aecfbab27e6c2acb99"}, + {file = "anyio-4.15.0.tar.gz", hash = "sha256:b5c620ed540725e2579c31b17bb995b3bf02c9281c9cace04c7d186380bab85e"}, + ] + + [package.dependencies] + exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} + idna = ">=2.8" + typing_extensions = {version = ">=4.16.0", markers = "python_version < \"3.15\""} + + [package.extras] + trio = ["trio (>=0.32.0)"] + + [[package]] + name = "async-timeout" + version = "5.0.1" + description = "Timeout context manager for asyncio programs" + optional = false + python-versions = ">=3.8" + files = [ + {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, + {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, + ] + + [[package]] + name = "attrs" + version = "26.1.0" + description = "Classes Without Boilerplate" + optional = false + python-versions = ">=3.9" + files = [ + {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, + {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, + ] + + [[package]] + name = "certifi" + version = "2026.7.22" + description = "Python package for providing Mozilla's CA Bundle." + optional = false + python-versions = ">=3.7" + files = [ + {file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"}, + {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, + ] + + [[package]] + name = "colorama" + version = "0.4.6" + description = "Cross-platform colored terminal text." + optional = false + python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" + files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, + ] + + [[package]] + name = "exceptiongroup" + version = "1.3.1" + description = "Backport of PEP 654 (exception groups)" + optional = false + python-versions = ">=3.7" + files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, + ] + + [package.dependencies] + typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + + [package.extras] + test = ["pytest (>=6)"] + + [[package]] + name = "execnet" + version = "2.1.2" + description = "execnet: rapid multi-Python deployment" + optional = false + python-versions = ">=3.8" + files = [ + {file = "execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec"}, + {file = "execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd"}, + ] + + [package.extras] + testing = ["hatch", "pre-commit", "pytest", "tox"] + + [[package]] + name = "fakeredis" + version = "2.38.0" + description = "Python implementation of redis API, can be used for testing purposes." + optional = false + python-versions = ">=3.8" + files = [ + {file = "fakeredis-2.38.0-py3-none-any.whl", hash = "sha256:d9fb0518c4eaa35f1f2c94df6b4a4c97ff3ca9f43d6cc8112317a9037d244301"}, + {file = "fakeredis-2.38.0.tar.gz", hash = "sha256:d2abfd24652f86501044499bf08c9d639db050f695eefc06b8b8b6f0bb24dbd6"}, + ] + + [package.dependencies] + lupa = {version = ">=2.1", optional = true, markers = "extra == \"lua\""} + redis = ">=4.3" + sortedcontainers = ">=2" + typing-extensions = {version = ">=4.7", markers = "python_version < \"3.11\""} + + [package.extras] + bf = ["pyprobables (>=0.6)"] + cf = ["pyprobables (>=0.6)"] + json = ["jsonpath-ng (>=1.6)"] + lua = ["lupa (>=2.1)"] + probabilistic = ["pyprobables (>=0.6)"] + valkey = ["valkey (>=6)"] + vectorset = ["jsonpath-ng (>=1.6)", "numpy (>=2.4.0)"] + + [[package]] + name = "frozenlist" + version = "1.8.0" + description = "A list-like structure which implements collections.abc.MutableSequence" + optional = false + python-versions = ">=3.9" + files = [ + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7"}, + {file = "frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967"}, + {file = "frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa"}, + {file = "frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed"}, + {file = "frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7"}, + {file = "frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda"}, + {file = "frozenlist-1.8.0-cp39-cp39-win32.whl", hash = "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087"}, + {file = "frozenlist-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a"}, + {file = "frozenlist-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103"}, + {file = "frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d"}, + {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"}, + ] + + [[package]] + name = "h11" + version = "0.16.0" + description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" + optional = false + python-versions = ">=3.8" + files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, + ] + + [[package]] + name = "httpcore" + version = "1.0.9" + description = "A minimal low-level HTTP client." + optional = false + python-versions = ">=3.8" + files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, + ] + + [package.dependencies] + certifi = "*" + h11 = ">=0.16" + + [package.extras] + asyncio = ["anyio (>=4.0,<5.0)"] + http2 = ["h2 (>=3,<5)"] + socks = ["socksio (==1.*)"] + trio = ["trio (>=0.22.0,<1.0)"] + + [[package]] + name = "httpx" + version = "0.28.1" + description = "The next generation HTTP client." + optional = false + python-versions = ">=3.8" + files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, + ] + + [package.dependencies] + anyio = "*" + certifi = "*" + httpcore = "==1.*" + idna = "*" + + [package.extras] + brotli = ["brotli", "brotlicffi"] + cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] + http2 = ["h2 (>=3,<5)"] + socks = ["socksio (==1.*)"] + zstd = ["zstandard (>=0.18.0)"] + + [[package]] + name = "httpx-aiohttp" + version = "0.1.12" + description = "Aiohttp transport for HTTPX" + optional = true + python-versions = ">=3.8" + files = [ + {file = "httpx_aiohttp-0.1.12-py3-none-any.whl", hash = "sha256:5b0eac39a7f360fa7867a60bcb46bb1024eada9c01cbfecdb54dc1edb3fb7141"}, + {file = "httpx_aiohttp-0.1.12.tar.gz", hash = "sha256:81feec51fd82c0ecfa0e9aaf1b1a6c2591260d5e2bcbeb7eb0277a78e610df2c"}, + ] + + [package.dependencies] + aiohttp = ">=3.10.0,<4" + httpx = ">=0.27.0" + + [[package]] + name = "idna" + version = "3.19" + description = "Internationalized Domain Names in Applications (IDNA)" + optional = false + python-versions = ">=3.9" + files = [ + {file = "idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4"}, + {file = "idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15"}, + ] + + [package.extras] + all = ["coverage (>=7.10.0)", "hypothesis (>=6.141.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.16.0)", "ty (>=0.0.37)"] + + [[package]] + name = "iniconfig" + version = "2.3.0" + description = "brain-dead simple config-ini parsing" + optional = false + python-versions = ">=3.10" + files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, + ] + + [[package]] + name = "lupa" + version = "2.8" + description = "Python wrapper around Lua and LuaJIT" + optional = false + python-versions = ">=3.8" + files = [ + {file = "lupa-2.8-cp310-abi3-win32.whl", hash = "sha256:c2a5fd15dc62374e1661a55f01744c9ec1c56f291ba4a0749d3af2174556e78f"}, + {file = "lupa-2.8-cp310-abi3-win_arm64.whl", hash = "sha256:9e304fb1c50cf23fd8882afbe1aa87525ef8a72667bcab3b37b2bbb2bc542269"}, + {file = "lupa-2.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:97bd01e90b8031e56a5fd5bb70605aea09f1dba675c1140308a52780f93d06f1"}, + {file = "lupa-2.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b5ebe1a13c45767919c86750b84fe2da9f6288b6f3cea4ce7660bb2abc9d921"}, + {file = "lupa-2.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:097e7d0f1719a88020b67c82e05d53d7973c166952393afcecfd8434c7e19a15"}, + {file = "lupa-2.8-cp310-cp310-win_amd64.whl", hash = "sha256:7bb223ee8f72d0dc076b0d65296ee72f1c69450f9d2fed5315f7707d98c4a03d"}, + {file = "lupa-2.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b12e43c1fb787189dfc28cd604aef0baa2cb95e27da19498d520361d0ace070a"}, + {file = "lupa-2.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f603391dffb256e36a79fd2044084d5f4b8a0a4c0e5ad291cd3ab3aaf1fd0a"}, + {file = "lupa-2.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f6f41c91366e7d0d474f87d81c1274af861f40812bf729c9f97ab4c8f3c7ac8"}, + {file = "lupa-2.8-cp311-cp311-win_amd64.whl", hash = "sha256:f5a6af145b0ea818f01d27bfe2583a4b538570bef61d22c8773e0eccf011234c"}, + {file = "lupa-2.8-cp312-abi3-macosx_10_13_x86_64.whl", hash = "sha256:f4342f4de76ae7ce2ab0672d36003bdb7e1a33252f293b569298ddd792e70e33"}, + {file = "lupa-2.8-cp312-abi3-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:4203fa1659315e939a5304e75001b8cc14234fb3cbb3ed86c049b0cc5d90fcee"}, + {file = "lupa-2.8-cp312-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:81f2d843ce668b653146c007467570210ae44be51dac6926666c51d49536f307"}, + {file = "lupa-2.8-cp312-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3d0cde2c77588d1c60875a4f34f059513476c6e1775351897195b51e0f3df08"}, + {file = "lupa-2.8-cp312-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e0d11b8f3a8dac6413f704fef7161d048bb10c58bdac6cbffa5e60efa56e9a3"}, + {file = "lupa-2.8-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:54cff414f21f8cd8c6be4aae52541f3b9cd39602b59e3a3db9b5c9f9f674ff18"}, + {file = "lupa-2.8-cp312-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:24b4d8af5558e549b70daf1547f5c1c1d664ecea9fc790f83efe5d75e9a93797"}, + {file = "lupa-2.8-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:ce86dff1ee7f7cf45f5622065ae991949dd7bb1703581cbc58a630137bb7ccf9"}, + {file = "lupa-2.8-cp312-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f4d01b2a08c70bbb883a9e082b6b36b89121ed5910b710f1ba11c73295ff4fba"}, + {file = "lupa-2.8-cp312-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:7f210d5a8353e510ea1199c42cf3cbdd630553bf2bc8fb4c00fea06fdec7c798"}, + {file = "lupa-2.8-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4f81a02806e7c7ad26d8c6fa222c8bef1b0c1b124347c879be880b41339d41e4"}, + {file = "lupa-2.8-cp312-abi3-win32.whl", hash = "sha256:360056453a7a4eaa4ac5a204c31a5a014b1eb2ee5490603234d2ba831684f1f2"}, + {file = "lupa-2.8-cp312-abi3-win_arm64.whl", hash = "sha256:1628371c6592a6d5650497a9e31fb2bb3a7e9883c1f301d1111265e484045af9"}, + {file = "lupa-2.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:450650f91c48c2415b0d59ab3abfcfda3b6efb5b858205f4d4bda8ad141fa529"}, + {file = "lupa-2.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27044f3363047f946b3d3aab9157cbd172b3538ada9ec1baef43432bf7d03a78"}, + {file = "lupa-2.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cf4f064a0e5531afce2d7d750120c10c10f9529139af6ca6150d13151034398"}, + {file = "lupa-2.8-cp312-cp312-win_amd64.whl", hash = "sha256:281bedc5deb92d31e649a3552edd662449365a635904fa4d5cb4509c7245e34e"}, + {file = "lupa-2.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45fc9da0145ecb0083ef5ff9975116cc784bd0258bdc2bd131ba15483ce18398"}, + {file = "lupa-2.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58e18afed57955b41130e269c78f53d4123ab86e236b53816f4cbffa25cb5d30"}, + {file = "lupa-2.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc47f536ac13a79cef47d29a2b205576a22841f042a2bcec1676b95806e7706a"}, + {file = "lupa-2.8-cp313-cp313-win_amd64.whl", hash = "sha256:ce9404c661dbac65cc9bed351ad45e797af93d30d70be309a3fa8209ac86d93b"}, + {file = "lupa-2.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:348c3f8ecabb6324dcbc05c2740d762ef8fcec7b06c79e45262ab97a217684e3"}, + {file = "lupa-2.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:951496471056061598a7d1729a6cdf48d662fec777a9f2d8aa5a1e62fd30e5a5"}, + {file = "lupa-2.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a591b9947ca347b41a63370e121d6e2b1458fe6dde9ae065029ec10a37f25ff4"}, + {file = "lupa-2.8-cp314-cp314-win_amd64.whl", hash = "sha256:3903c9cf628dae2f56405503247b77a61a3a61bd2dda470e336950c74776d55d"}, + {file = "lupa-2.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f711a8ab0486b9ac6fdda94a22ddcfbc9f0d4a27e3a8cf1bf79c6e48b33017c1"}, + {file = "lupa-2.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc51250e76367a3e27fcd01dc769b9bfcbbc34f48df48dde53d6af6e75b7eaa5"}, + {file = "lupa-2.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8a22088a552828958603323f0a5c4b3e11e03b75d0bf4c965ef879de9b60a8d"}, + {file = "lupa-2.8-cp314-cp314t-win32.whl", hash = "sha256:4f7c553c1d8cfffbe85d81daef730d12cae4b6002d457542914da0ac8a1145b3"}, + {file = "lupa-2.8-cp314-cp314t-win_amd64.whl", hash = "sha256:d8766aff03a78c80ad2d188a8bdb216de5ec838359cd87e05bbdfa56394a6105"}, + {file = "lupa-2.8-cp314-cp314t-win_arm64.whl", hash = "sha256:91d622777febda3ab1bed1d45295f2f32a4680c7b3d7caf8c669998ed5c44118"}, + {file = "lupa-2.8-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81b283bfb13cc43fa4910fc98ec110ab861bcb39680f48b266f99d6e3be1049e"}, + {file = "lupa-2.8-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf45d15d424cee52fd67341e96e2b1dde0658ae90eb156ac56aa0d8330bc38"}, + {file = "lupa-2.8-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33e7e5aebca64b154b0a1679caf79e19254ff37bba51e87abab6848f97cb2de1"}, + {file = "lupa-2.8-cp38-cp38-win32.whl", hash = "sha256:e8d4f4dd4acf4a0e42adc6b1ad220e1c86fe3028402c2f78bd0728a6d241bbe9"}, + {file = "lupa-2.8-cp38-cp38-win_amd64.whl", hash = "sha256:1ac2b1ec7504e6148cba1bc35ac36c74d18a0ca6d367ffe7e78a3773c2694c0e"}, + {file = "lupa-2.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b036738282a5acd2e71fdddb317c9df8b87c1673aa57f403d05fcc2be8abc4ba"}, + {file = "lupa-2.8-cp39-abi3-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:ac6b6e8d0e617e26a98cbb44880bcd75de5d32b3ad7b3b3793583909292b47ed"}, + {file = "lupa-2.8-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ba3a7dd839f90c3d2e53bebe3c192b1f3f9fd720a6781256405123211fd0dce6"}, + {file = "lupa-2.8-cp39-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d7edb13a7a5250b5c6c22d1495d9e842b5c9fc5081c8fe6b5efe2112fe3e41f9"}, + {file = "lupa-2.8-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:891f72e0bffbed1e4175f975aeb2a083956586a100066525e1be485f617f7b25"}, + {file = "lupa-2.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a295f87b5b7ebbfd5191932e8cb0e51df3c7769101ac6b6c7d7c9fb27bfd1307"}, + {file = "lupa-2.8-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4fe5d7a810b64ea8511eb885fc8cdde042ee5ff7b7d08ae78f32449756acb177"}, + {file = "lupa-2.8-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:bfc470012ef66ad064c7bd77416af03a3452ef630b04b9012595ea13f2e54518"}, + {file = "lupa-2.8-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:250e035fdaffe8c87093e3ebc206ac29a26131b1568ea711d780c26001ce96e7"}, + {file = "lupa-2.8-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:b9bddb09acfffb4f828f790f444b11dc0cca591afea1a244d9329eea2d20c003"}, + {file = "lupa-2.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2e64acbbd47e9b82a64405a39e0d2b36a5a7dad8ab41c0f3437f572f7d282ba3"}, + {file = "lupa-2.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f6ddca4774d5ca451768a95e378a3aa041076e29f4613b8562f8e98efb6690fd"}, + {file = "lupa-2.8-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ffcfd8e19f943ad459136b3f60f085ae4948f024192a93ca4b4ac3023ec88d8"}, + {file = "lupa-2.8-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f3f3955f65f9fde2dc6eda3041ccd394cf54d4bf083f0cdf6feb3d58e5f38d3"}, + {file = "lupa-2.8-cp39-cp39-win32.whl", hash = "sha256:9e76e45057cfcaa20ee3422c2289a91f9d51783d020da3570ee226de8f6e71cd"}, + {file = "lupa-2.8-cp39-cp39-win_amd64.whl", hash = "sha256:6fbcc9911f05c67affbd225fc024268e61e98a18ad1b1c2aed6c8796e4056554"}, + {file = "lupa-2.8-cp39-cp39-win_arm64.whl", hash = "sha256:6c817d5421094507662e5f8feb8cd1e154c10879921c06079b6063be9d8f33c5"}, + {file = "lupa-2.8-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e4e5103bbddcdd2458fb2ccae6c8ba11c9997c711d7e379e0d45551d109c76"}, + {file = "lupa-2.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7667001804657496dee9feced2daae5000b4604a3218dd8e6b7b754982ba88b8"}, + {file = "lupa-2.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:86f6f668966965b15247dc32d064cfe7be67b71e584ccfacbe2f637575296878"}, + {file = "lupa-2.8.tar.gz", hash = "sha256:d8022641b9ec8ecf2c5ecbe9f47e5a70e0b87c4b5ae921b92cb02a638e0acd08"}, + ] + + [[package]] + name = "multidict" + version = "6.7.1" + description = "multidict implementation" + optional = false + python-versions = ">=3.9" + files = [ + {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5"}, + {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8"}, + {file = "multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505"}, + {file = "multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122"}, + {file = "multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df"}, + {file = "multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db"}, + {file = "multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d"}, + {file = "multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e"}, + {file = "multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa"}, + {file = "multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a"}, + {file = "multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b"}, + {file = "multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6"}, + {file = "multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172"}, + {file = "multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd"}, + {file = "multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba"}, + {file = "multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511"}, + {file = "multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19"}, + {file = "multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf"}, + {file = "multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23"}, + {file = "multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2"}, + {file = "multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33"}, + {file = "multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3"}, + {file = "multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5"}, + {file = "multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df"}, + {file = "multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1"}, + {file = "multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963"}, + {file = "multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108"}, + {file = "multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32"}, + {file = "multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8"}, + {file = "multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118"}, + {file = "multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee"}, + {file = "multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2"}, + {file = "multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b"}, + {file = "multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d"}, + {file = "multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f"}, + {file = "multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5"}, + {file = "multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581"}, + {file = "multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a"}, + {file = "multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2"}, + {file = "multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7"}, + {file = "multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5"}, + {file = "multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2"}, + {file = "multidict-6.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f"}, + {file = "multidict-6.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358"}, + {file = "multidict-6.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5"}, + {file = "multidict-6.7.1-cp39-cp39-win32.whl", hash = "sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0"}, + {file = "multidict-6.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4"}, + {file = "multidict-6.7.1-cp39-cp39-win_arm64.whl", hash = "sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9"}, + {file = "multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56"}, + {file = "multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d"}, + ] + + [package.dependencies] + typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} + + [[package]] + name = "mypy" + version = "1.13.0" + description = "Optional static typing for Python" + optional = false + python-versions = ">=3.8" + files = [ + {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, + {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, + {file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"}, + {file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"}, + {file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"}, + {file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"}, + {file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"}, + {file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"}, + {file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"}, + {file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"}, + {file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"}, + {file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"}, + {file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"}, + {file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"}, + {file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"}, + {file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"}, + {file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"}, + {file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"}, + {file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"}, + {file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"}, + {file = "mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a"}, + {file = "mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb"}, + {file = "mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b"}, + {file = "mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74"}, + {file = "mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6"}, + {file = "mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc"}, + {file = "mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732"}, + {file = "mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc"}, + {file = "mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d"}, + {file = "mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24"}, + {file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"}, + {file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"}, + ] + + [package.dependencies] + mypy-extensions = ">=1.0.0" + tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} + typing-extensions = ">=4.6.0" + + [package.extras] + dmypy = ["psutil (>=4.0)"] + faster-cache = ["orjson"] + install-types = ["pip"] + mypyc = ["setuptools (>=50)"] + reports = ["lxml"] + + [[package]] + name = "mypy-extensions" + version = "1.1.0" + description = "Type system extensions for programs checked with the mypy type checker." + optional = false + python-versions = ">=3.8" + files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, + ] + + [[package]] + name = "packaging" + version = "26.3" + description = "Core utilities for Python packages" + optional = false + python-versions = ">=3.9" + files = [ + {file = "packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c"}, + {file = "packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79"}, + ] + + [[package]] + name = "pluggy" + version = "1.6.0" + description = "plugin and hook calling mechanisms for python" + optional = false + python-versions = ">=3.9" + files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, + ] + + [package.extras] + dev = ["pre-commit", "tox"] + testing = ["coverage", "pytest", "pytest-benchmark"] + + [[package]] + name = "propcache" + version = "0.5.2" + description = "Accelerated property cache" + optional = false + python-versions = ">=3.10" + files = [ + {file = "propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b"}, + {file = "propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c"}, + {file = "propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb"}, + {file = "propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e"}, + {file = "propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e"}, + {file = "propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b"}, + {file = "propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d"}, + {file = "propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d"}, + {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0"}, + {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b"}, + {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf"}, + {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf"}, + {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e"}, + {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274"}, + {file = "propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe"}, + {file = "propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d"}, + {file = "propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5"}, + {file = "propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78"}, + {file = "propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959"}, + {file = "propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7"}, + {file = "propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511"}, + {file = "propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660"}, + {file = "propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66"}, + {file = "propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b"}, + {file = "propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67"}, + {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f"}, + {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c"}, + {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0"}, + {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6"}, + {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27"}, + {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f"}, + {file = "propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0"}, + {file = "propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82"}, + {file = "propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab"}, + {file = "propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba"}, + {file = "propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a"}, + {file = "propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf"}, + {file = "propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144"}, + {file = "propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9"}, + {file = "propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42"}, + {file = "propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476"}, + {file = "propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba"}, + {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a"}, + {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64"}, + {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913"}, + {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1"}, + {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33"}, + {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a"}, + {file = "propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031"}, + {file = "propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42"}, + {file = "propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84"}, + {file = "propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a"}, + {file = "propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117"}, + {file = "propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098"}, + {file = "propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4"}, + {file = "propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e"}, + {file = "propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7"}, + {file = "propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d"}, + {file = "propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a"}, + {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2"}, + {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa"}, + {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853"}, + {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a"}, + {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704"}, + {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4"}, + {file = "propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d"}, + {file = "propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757"}, + {file = "propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f"}, + {file = "propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d"}, + {file = "propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa"}, + {file = "propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94"}, + {file = "propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164"}, + {file = "propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f"}, + {file = "propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c"}, + {file = "propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc"}, + {file = "propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f"}, + {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb"}, + {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751"}, + {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836"}, + {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f"}, + {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55"}, + {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568"}, + {file = "propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191"}, + {file = "propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7"}, + {file = "propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96"}, + {file = "propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999"}, + {file = "propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e"}, + {file = "propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539"}, + {file = "propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e"}, + {file = "propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979"}, + {file = "propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80"}, + {file = "propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825"}, + {file = "propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39"}, + {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4"}, + {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5"}, + {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702"}, + {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3"}, + {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5"}, + {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4"}, + {file = "propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0"}, + {file = "propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c"}, + {file = "propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0"}, + {file = "propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb"}, + {file = "propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078"}, + {file = "propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa"}, + {file = "propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917"}, + {file = "propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe"}, + {file = "propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03"}, + {file = "propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335"}, + {file = "propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285"}, + {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837"}, + {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8"}, + {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366"}, + {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56"}, + {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d"}, + {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2"}, + {file = "propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821"}, + {file = "propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370"}, + {file = "propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6"}, + {file = "propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe"}, + {file = "propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427"}, + ] + + [[package]] + name = "pydantic" + version = "2.13.5" + description = "Data validation using Python type hints" + optional = false + python-versions = ">=3.9" + files = [ + {file = "pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73"}, + {file = "pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08"}, + ] + + [package.dependencies] + annotated-types = ">=0.6.0" + pydantic-core = "2.46.5" + typing-extensions = ">=4.14.1" + typing-inspection = ">=0.4.2" + + [package.extras] + email = ["email-validator (>=2.0.0)"] + timezone = ["tzdata"] + + [[package]] + name = "pydantic-core" + version = "2.46.5" + description = "Core functionality for Pydantic validation and serialization" + optional = false + python-versions = ">=3.9" + files = [ + {file = "pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6"}, + {file = "pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736"}, + {file = "pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3"}, + {file = "pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f"}, + {file = "pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1"}, + {file = "pydantic_core-2.46.5-cp310-cp310-win32.whl", hash = "sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069"}, + {file = "pydantic_core-2.46.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d"}, + {file = "pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f"}, + {file = "pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e"}, + {file = "pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2"}, + {file = "pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689"}, + {file = "pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec"}, + {file = "pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129"}, + {file = "pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c"}, + {file = "pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8"}, + {file = "pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d"}, + {file = "pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5"}, + {file = "pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3"}, + {file = "pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b"}, + {file = "pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0"}, + {file = "pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b"}, + {file = "pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8"}, + {file = "pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084"}, + {file = "pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0"}, + {file = "pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5"}, + {file = "pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e"}, + {file = "pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed"}, + {file = "pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519"}, + {file = "pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea"}, + {file = "pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5"}, + {file = "pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575"}, + {file = "pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355"}, + {file = "pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821"}, + {file = "pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2"}, + {file = "pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47"}, + {file = "pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a"}, + {file = "pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074"}, + {file = "pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0"}, + {file = "pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f"}, + {file = "pydantic_core-2.46.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c583b927a8838dab890706a6fa7573fbb8b70e24000ef9f7238e2d6f6435a5ed"}, + {file = "pydantic_core-2.46.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cdc8b74ecc48c0cb1e9607a05ec4e9e88db60a19ffcc9a1d5f9088ede40c8dc0"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b10e3e8fd7ddc2bd915848a2768e44c15b22936f1cc54c462ad1164deb02655"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f077d0b97ab11fa7dcc633fca53515f290bca8a8a633e966d5b6d1879d9ed01a"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b0fc826b16c55e561e5d2a0c5c77b051ba1d92808118c4e4b5390f5e0cf191d"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef3fbbf161dc9351a2fe0422e51b129f9e97e42385bd0320b309c15f7d287dd8"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:978e7b97d4824b5be09c69fb70507cbde3b0323fc147332ca40a94d9a6a0ebbf"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:9b68938dd5b0c783d88ff8e2dcc69451b5eb936fe212d516b21b9d5567f6d464"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:771cf63ae0b1b50dd22e5f3e3549fab5f3f4ff1635d352a9e1a97fe01c7b2e64"}, + {file = "pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7c6be839a5a8312626b32029a415644a0846b420bc8b52b95b28cd92da162168"}, + {file = "pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:895395f8918627b04efb1ad2a4cf605387143300ba03304cd1dfa6d03f5e095e"}, + {file = "pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fc8515076c11f3cfdf4fb142dcca0fe384b1230a3b5415458ac84f3e0903ec13"}, + {file = "pydantic_core-2.46.5-cp39-cp39-win32.whl", hash = "sha256:3d2652072b2d774947ba5cf78a9e59644ac62ee572daf6dd2e1dfe905e15b2b7"}, + {file = "pydantic_core-2.46.5-cp39-cp39-win_amd64.whl", hash = "sha256:3aa166e99c4f2985407fb8714aebede877ecb5455cf321b606adca926d30d5a0"}, + {file = "pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2"}, + {file = "pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c"}, + {file = "pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47"}, + {file = "pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a"}, + {file = "pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942"}, + {file = "pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f"}, + {file = "pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433"}, + {file = "pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266"}, + {file = "pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc"}, + ] + + [package.dependencies] + typing-extensions = ">=4.14.1" + + [[package]] + name = "pyjwt" + version = "2.13.0" + description = "JSON Web Token implementation in Python" + optional = false + python-versions = ">=3.9" + files = [ + {file = "pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728"}, + {file = "pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423"}, + ] + + [package.dependencies] + typing_extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + + [package.extras] + crypto = ["cryptography (>=3.4.0)"] + + [[package]] + name = "pytest" + version = "7.4.4" + description = "pytest: simple powerful testing with Python" + optional = false + python-versions = ">=3.7" + files = [ + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, + ] + + [package.dependencies] + colorama = {version = "*", markers = "sys_platform == \"win32\""} + exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} + iniconfig = "*" + packaging = "*" + pluggy = ">=0.12,<2.0" + tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + + [package.extras] + testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + + [[package]] + name = "pytest-asyncio" + version = "0.23.8" + description = "Pytest support for asyncio" + optional = false + python-versions = ">=3.8" + files = [ + {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, + {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, + ] + + [package.dependencies] + pytest = ">=7.0.0,<9" + + [package.extras] + docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] + testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + + [[package]] + name = "pytest-xdist" + version = "3.8.0" + description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" + optional = false + python-versions = ">=3.9" + files = [ + {file = "pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88"}, + {file = "pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1"}, + ] + + [package.dependencies] + execnet = ">=2.1" + pytest = ">=7.0.0" + + [package.extras] + psutil = ["psutil (>=3.0)"] + setproctitle = ["setproctitle"] + testing = ["filelock"] + + [[package]] + name = "python-dateutil" + version = "2.9.0.post0" + description = "Extensions to the standard Python datetime module" + optional = false + python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, + ] + + [package.dependencies] + six = ">=1.5" + + [[package]] + name = "redis" + version = "5.3.1" + description = "Python client for Redis database and key-value store" + optional = false + python-versions = ">=3.8" + files = [ + {file = "redis-5.3.1-py3-none-any.whl", hash = "sha256:dc1909bd24669cc31b5f67a039700b16ec30571096c5f1f0d9d2324bff31af97"}, + {file = "redis-5.3.1.tar.gz", hash = "sha256:ca49577a531ea64039b5a36db3d6cd1a0c7a60c34124d46924a45b956e8cf14c"}, + ] + + [package.dependencies] + async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} + PyJWT = ">=2.9.0" + + [package.extras] + hiredis = ["hiredis (>=3.0.0)"] + ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==23.2.1)", "requests (>=2.31.0)"] + + [[package]] + name = "ruff" + version = "0.11.5" + description = "An extremely fast Python linter and code formatter, written in Rust." + optional = false + python-versions = ">=3.7" + files = [ + {file = "ruff-0.11.5-py3-none-linux_armv6l.whl", hash = "sha256:2561294e108eb648e50f210671cc56aee590fb6167b594144401532138c66c7b"}, + {file = "ruff-0.11.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac12884b9e005c12d0bd121f56ccf8033e1614f736f766c118ad60780882a077"}, + {file = "ruff-0.11.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4bfd80a6ec559a5eeb96c33f832418bf0fb96752de0539905cf7b0cc1d31d779"}, + {file = "ruff-0.11.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0947c0a1afa75dcb5db4b34b070ec2bccee869d40e6cc8ab25aca11a7d527794"}, + {file = "ruff-0.11.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ad871ff74b5ec9caa66cb725b85d4ef89b53f8170f47c3406e32ef040400b038"}, + {file = "ruff-0.11.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6cf918390cfe46d240732d4d72fa6e18e528ca1f60e318a10835cf2fa3dc19f"}, + {file = "ruff-0.11.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:56145ee1478582f61c08f21076dc59153310d606ad663acc00ea3ab5b2125f82"}, + {file = "ruff-0.11.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5f66f8f1e8c9fc594cbd66fbc5f246a8d91f916cb9667e80208663ec3728304"}, + {file = "ruff-0.11.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80b4df4d335a80315ab9afc81ed1cff62be112bd165e162b5eed8ac55bfc8470"}, + {file = "ruff-0.11.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3068befab73620b8a0cc2431bd46b3cd619bc17d6f7695a3e1bb166b652c382a"}, + {file = "ruff-0.11.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5da2e710a9641828e09aa98b92c9ebbc60518fdf3921241326ca3e8f8e55b8b"}, + {file = "ruff-0.11.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ef39f19cb8ec98cbc762344921e216f3857a06c47412030374fffd413fb8fd3a"}, + {file = "ruff-0.11.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b2a7cedf47244f431fd11aa5a7e2806dda2e0c365873bda7834e8f7d785ae159"}, + {file = "ruff-0.11.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:81be52e7519f3d1a0beadcf8e974715b2dfc808ae8ec729ecfc79bddf8dbb783"}, + {file = "ruff-0.11.5-py3-none-win32.whl", hash = "sha256:e268da7b40f56e3eca571508a7e567e794f9bfcc0f412c4b607931d3af9c4afe"}, + {file = "ruff-0.11.5-py3-none-win_amd64.whl", hash = "sha256:6c6dc38af3cfe2863213ea25b6dc616d679205732dc0fb673356c2d69608f800"}, + {file = "ruff-0.11.5-py3-none-win_arm64.whl", hash = "sha256:67e241b4314f4eacf14a601d586026a962f4002a475aa702c69980a38087aa4e"}, + {file = "ruff-0.11.5.tar.gz", hash = "sha256:cae2e2439cb88853e421901ec040a758960b576126dab520fa08e9de431d1bef"}, + ] + + [[package]] + name = "six" + version = "1.17.0" + description = "Python 2 and 3 compatibility utilities" + optional = false + python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, + ] + + [[package]] + name = "sortedcontainers" + version = "2.4.0" + description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" + optional = false + python-versions = "*" + files = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, + ] + + [[package]] + name = "tomli" + version = "2.4.1" + description = "A lil' TOML parser" + optional = false + python-versions = ">=3.8" + files = [ + {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"}, + {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"}, + {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"}, + {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"}, + {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"}, + {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"}, + {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"}, + {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"}, + {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"}, + {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"}, + {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"}, + {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"}, + {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"}, + {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"}, + {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"}, + {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"}, + {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, + {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, + ] + + [[package]] + name = "types-python-dateutil" + version = "2.9.0.20260807" + description = "Typing stubs for python-dateutil" + optional = false + python-versions = ">=3.10" + files = [ + {file = "types_python_dateutil-2.9.0.20260807-py3-none-any.whl", hash = "sha256:54aa3707350ed7a9cc0776fd2f6739679d6967d11b40150985e81edcb86df4db"}, + {file = "types_python_dateutil-2.9.0.20260807.tar.gz", hash = "sha256:e0b8a90d464c8684c66b7b8e4556d9074afdddcc56ca45323f0987134f9e7034"}, + ] + + [[package]] + name = "typing-extensions" + version = "4.16.0" + description = "Backported and Experimental Type Hints for Python 3.9+" + optional = false + python-versions = ">=3.9" + files = [ + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, + ] + + [[package]] + name = "typing-inspection" + version = "0.4.4" + description = "Runtime typing introspection tools" + optional = false + python-versions = ">=3.10" + files = [ + {file = "typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147"}, + {file = "typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47"}, + ] + + [package.dependencies] + typing-extensions = ">=4.15.0" + + [[package]] + name = "wasmtime" + version = "48.0.0" + description = "A WebAssembly runtime powered by Wasmtime" + optional = true + python-versions = ">=3.9" + files = [ + {file = "wasmtime-48.0.0-py3-none-android_26_arm64_v8a.whl", hash = "sha256:a55abf132fe238b843a963c68cd1a30d8f686c1bc75d8fbf042d8b7a1d51ee36"}, + {file = "wasmtime-48.0.0-py3-none-android_26_x86_64.whl", hash = "sha256:d8e94276ff6c0c5ce73ee16ccbacb00b3512a4b3a664749380705d81ee06a23c"}, + {file = "wasmtime-48.0.0-py3-none-any.whl", hash = "sha256:49c9ee43e9cf59ad7453ac65dce0cc4b885837904dd3cfd45faafe930defe14a"}, + {file = "wasmtime-48.0.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:50e1ea81a3bec537d00e076722dfdc48978a56ea24619d8153aa1f75b11796b9"}, + {file = "wasmtime-48.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ea69889a3c51702e9da5f5f441027ca934f7758f8926a4ed167b0d6877f092e8"}, + {file = "wasmtime-48.0.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:58544d539053dff7bd4cf30c40d7a540862d683013c0dfa6ba46a063f5b682f7"}, + {file = "wasmtime-48.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:26fce3613fefbe29a28e9d659dca3326e800593858e5758cad086eb802b3b766"}, + {file = "wasmtime-48.0.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:77f6b75db20be065e205e7af814d4e4f06784c3a00eb346e8c76148ecb4afe5a"}, + {file = "wasmtime-48.0.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:62b241c8d5dfb59ff8af1ccaa5351f0ab7aba8cc872f7d80e0e3c95d54c13562"}, + {file = "wasmtime-48.0.0-py3-none-win_amd64.whl", hash = "sha256:21fa500e70f3819a8c0539c3f0be6b3b81ec3c630bb90c47dba4d8a2c1d4c698"}, + {file = "wasmtime-48.0.0-py3-none-win_arm64.whl", hash = "sha256:09cd5e14df80a3a8d447428a548583181c568ea2e617419d23600deff21d4b82"}, + {file = "wasmtime-48.0.0.tar.gz", hash = "sha256:dba27d59209fac703e7d5753af78c2af2c1cd1ed735f520ec76dc31c60a05815"}, + ] + + [package.extras] + testing = ["coverage", "pycparser", "pytest", "pytest-mypy"] + + [[package]] + name = "websockets" + version = "16.1.1" + description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" + optional = true + python-versions = ">=3.10" + files = [ + {file = "websockets-16.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:49ae99bdfcae803a885c926bf14f886196e84925395bb3f568fef5c0f0979d7d"}, + {file = "websockets-16.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5bfd1ac19b1b9986a9c95a82d5e23a391ebb09e12c34d7be6094b86efcc35731"}, + {file = "websockets-16.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9246a0d063cfcbcc85f2359dd6876d681213f4790832272aa16641b4ed5d64d4"}, + {file = "websockets-16.1.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1214e673c404684b9bf7154f5cf43b45025b1a6160fac3a9e438e9c1a97e22cb"}, + {file = "websockets-16.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90001d893bc368e302ef168d82130b4e4fdd27b85fa094682df9b667c2d48838"}, + {file = "websockets-16.1.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:130937b167a52af203c8d58e78d67705874e82759862e3b9671a452fec4abc87"}, + {file = "websockets-16.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c9f23004a3d40e89c01a7955d186a6cc83418d93b749701944ce2de3e95a1f3"}, + {file = "websockets-16.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f55f0b01956a094c8587146d9558c91937e78789c333860ffaf35931a6e5dbc4"}, + {file = "websockets-16.1.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6aaface73b9c71974c6497366d8b9628357f6c9749e09c4ea3610176c63f2ae3"}, + {file = "websockets-16.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc0fad4933f427acd5b1cec210f3ea6dce7089e1724e4b9ec6ef47c6c04d1b3b"}, + {file = "websockets-16.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f2769a0344a09e9ccf5b3cce538bc75a51b53eff3275d3896310c8552049195d"}, + {file = "websockets-16.1.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f70541f3104339f59f830522d94ebadb1bf47426287381623443d8bb1cdbf33d"}, + {file = "websockets-16.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:dc385593a42e31cd6fb60c19f0ecb015b386603818fc2c6c274fb42bd2bb4165"}, + {file = "websockets-16.1.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:387e8e4aa5df2f90b198fa3cad3478822a89cf905b6a6d6c97dc3664689640cc"}, + {file = "websockets-16.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fd46fff7eb62c24804d234f0051c7a8ea81285ad63e0337d3dcf33ca82aee58a"}, + {file = "websockets-16.1.1-cp310-cp310-win32.whl", hash = "sha256:7883388947767080f094950b342b30d35a2a06b849cd967c422fa0db72b40ea9"}, + {file = "websockets-16.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:d57685547e0060cc6fd90ee6a28405d6bd395e525545f13c8d7cd99c78afd79f"}, + {file = "websockets-16.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c"}, + {file = "websockets-16.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a"}, + {file = "websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22"}, + {file = "websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2"}, + {file = "websockets-16.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01"}, + {file = "websockets-16.1.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0"}, + {file = "websockets-16.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29"}, + {file = "websockets-16.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512"}, + {file = "websockets-16.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3"}, + {file = "websockets-16.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57"}, + {file = "websockets-16.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3"}, + {file = "websockets-16.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648"}, + {file = "websockets-16.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d"}, + {file = "websockets-16.1.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be"}, + {file = "websockets-16.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81"}, + {file = "websockets-16.1.1-cp311-cp311-win32.whl", hash = "sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57"}, + {file = "websockets-16.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a"}, + {file = "websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00"}, + {file = "websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b"}, + {file = "websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175"}, + {file = "websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1"}, + {file = "websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15"}, + {file = "websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa"}, + {file = "websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab"}, + {file = "websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847"}, + {file = "websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428"}, + {file = "websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf"}, + {file = "websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751"}, + {file = "websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f"}, + {file = "websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2"}, + {file = "websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383"}, + {file = "websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3"}, + {file = "websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747"}, + {file = "websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7"}, + {file = "websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1"}, + {file = "websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df"}, + {file = "websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac"}, + {file = "websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8"}, + {file = "websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6"}, + {file = "websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854"}, + {file = "websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a"}, + {file = "websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49"}, + {file = "websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785"}, + {file = "websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56"}, + {file = "websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509"}, + {file = "websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1"}, + {file = "websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead"}, + {file = "websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e"}, + {file = "websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87"}, + {file = "websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea"}, + {file = "websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68"}, + {file = "websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8"}, + {file = "websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293"}, + {file = "websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051"}, + {file = "websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1"}, + {file = "websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7"}, + {file = "websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31"}, + {file = "websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0"}, + {file = "websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3"}, + {file = "websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562"}, + {file = "websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b"}, + {file = "websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a"}, + {file = "websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c"}, + {file = "websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499"}, + {file = "websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985"}, + {file = "websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9"}, + {file = "websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328"}, + {file = "websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc"}, + {file = "websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573"}, + {file = "websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999"}, + {file = "websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe"}, + {file = "websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d"}, + {file = "websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392"}, + {file = "websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7"}, + {file = "websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499"}, + {file = "websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43"}, + {file = "websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458"}, + {file = "websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62"}, + {file = "websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb"}, + {file = "websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51"}, + {file = "websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0"}, + {file = "websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217"}, + {file = "websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737"}, + {file = "websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7"}, + {file = "websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231"}, + {file = "websockets-16.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869"}, + {file = "websockets-16.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9"}, + {file = "websockets-16.1.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e"}, + {file = "websockets-16.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d"}, + {file = "websockets-16.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5"}, + {file = "websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3"}, + {file = "websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57"}, + ] + + [[package]] + name = "yarl" + version = "1.24.5" + description = "Yet another URL library" + optional = false + python-versions = ">=3.10" + files = [ + {file = "yarl-1.24.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750"}, + {file = "yarl-1.24.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2"}, + {file = "yarl-1.24.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871"}, + {file = "yarl-1.24.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ac73abdc7ab75610f95a8fd994c6457e87752b02a63987e188f937a1fc180f0"}, + {file = "yarl-1.24.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4d97a951a81039050e45f04e96689b58b8243fa5e62aa14fe67cb6075300885e"}, + {file = "yarl-1.24.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe7b7bb170daccbba19ad33012d2b15f1e7942296fd4d45fc1b79013da8cc0f2"}, + {file = "yarl-1.24.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89a1bbb58e0e3f7a283653d854b1e95d65e5cfd4af224dac5f02629ec1a3e621"}, + {file = "yarl-1.24.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba"}, + {file = "yarl-1.24.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4103b77b8a8225e413107d2349b65eb3c1c52627b5cc5c3c4c1c6a798b218950"}, + {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9f3e9c8a9ecffa57bef8fb4fa19e5fa4d2d8307cf6bac5b1fca5e5860f4ba00"}, + {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0ebc836c47a6477e182169c6a476fc691d12b518894bf7dd2572f0d59f1c7ed"}, + {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:96d30286dd02679e32a39aa8f0b7498fc847fcda46cfc09df5513e82ce252440"}, + {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:fd8c81f346b58f45818d09ea11db69a8d5fd34a224b79871f6d44f12cd7977b1"}, + {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5c55256dee8f4b27bfbf636c8363383c7c8db7890c7cba5217d7bd5f5f21dab6"}, + {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9f4d8cf085a4c6a40fb97ea0f46938a8df43c85d31f9d45e2a8867ea9293790d"}, + {file = "yarl-1.24.5-cp310-cp310-win_amd64.whl", hash = "sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224"}, + {file = "yarl-1.24.5-cp310-cp310-win_arm64.whl", hash = "sha256:8a6987eaad834cb32dd57d9d582225f0054a5d1af706ccfbbdba735af4927e13"}, + {file = "yarl-1.24.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3"}, + {file = "yarl-1.24.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a"}, + {file = "yarl-1.24.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840"}, + {file = "yarl-1.24.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966"}, + {file = "yarl-1.24.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723"}, + {file = "yarl-1.24.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb"}, + {file = "yarl-1.24.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780"}, + {file = "yarl-1.24.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e"}, + {file = "yarl-1.24.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2"}, + {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58"}, + {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61"}, + {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6"}, + {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f"}, + {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077"}, + {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd"}, + {file = "yarl-1.24.5-cp311-cp311-win_amd64.whl", hash = "sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25"}, + {file = "yarl-1.24.5-cp311-cp311-win_arm64.whl", hash = "sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a"}, + {file = "yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d"}, + {file = "yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec"}, + {file = "yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c"}, + {file = "yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54"}, + {file = "yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12"}, + {file = "yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d"}, + {file = "yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1"}, + {file = "yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9"}, + {file = "yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027"}, + {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b"}, + {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293"}, + {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e"}, + {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b"}, + {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce"}, + {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba"}, + {file = "yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b"}, + {file = "yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c"}, + {file = "yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2"}, + {file = "yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb"}, + {file = "yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075"}, + {file = "yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff"}, + {file = "yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448"}, + {file = "yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f"}, + {file = "yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd"}, + {file = "yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16"}, + {file = "yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213"}, + {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24"}, + {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385"}, + {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c"}, + {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4"}, + {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144"}, + {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4"}, + {file = "yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740"}, + {file = "yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1"}, + {file = "yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76"}, + {file = "yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d"}, + {file = "yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75"}, + {file = "yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9"}, + {file = "yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede"}, + {file = "yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca"}, + {file = "yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027"}, + {file = "yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9"}, + {file = "yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41"}, + {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373"}, + {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36"}, + {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0"}, + {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5"}, + {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5"}, + {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4"}, + {file = "yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad"}, + {file = "yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f"}, + {file = "yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88"}, + {file = "yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba"}, + {file = "yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928"}, + {file = "yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f"}, + {file = "yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95"}, + {file = "yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc"}, + {file = "yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da"}, + {file = "yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a"}, + {file = "yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0"}, + {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498"}, + {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104"}, + {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331"}, + {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550"}, + {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6"}, + {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047"}, + {file = "yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104"}, + {file = "yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688"}, + {file = "yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7"}, + {file = "yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f"}, + ] + + [package.dependencies] + idna = ">=2.0" + multidict = ">=4.0" + propcache = ">=0.2.1" + + [extras] + aiohttp = ["httpx-aiohttp"] + datastream = ["wasmtime", "websockets"] + rulesengine = ["wasmtime"] + + [metadata] + lock-version = "2.0" + python-versions = "^3.10" + content-hash = "b97aece3e401780396eb371b240c4c82f59799ccece668843b9f37dd9b0af950" + src/schematic/leases/__init__.py: | + """Client-mode credit leases: local lease slots, reservations, and their manager. + + Async only. Client-mode leases ride on DataStream, which this SDK offers on + ``AsyncSchematic`` alone, so there is no synchronous variant. + + The in-memory stores gate within one process; the Redis stores gate across + pods and share their key layout with the Node SDK, so mixed fleets agree on + every lease. + """ + + from .check import CheckDataStream, CreditCheckDeps, check_with_lease + from .lease_manager import CreditsWireClient, LeaseGrant, LeaseManager, LeaseWireClient + from .lease_store import InMemoryLeaseStore, LeaseStore, lease_key + from .redis_lease_store import RedisLeaseStore + from .redis_reservation_store import RedisReservationStore + from .reservation_store import InMemoryReservationStore, ReservationStore + from .track import ( + ReservationConsumeResult, + build_reservation_track_event, + consume_reservation_and_build_event, + settled_quantity, + ) + from .types import ( + DEFAULT_LEASE_DURATION, + DEFAULT_LEASE_SIZE, + DEFAULT_LOW_WATER_MARK, + DEFAULT_PREWARM_RESOLVE_TIMEOUT, + DEFAULT_RESERVATION_TTL, + DEFAULT_SWEEP_INTERVAL, + Clock, + LeaseConfig, + LeaseConfigOverride, + LeaseState, + ReservationRecord, + ResolvedLeaseConfig, + is_valid_quantity, + resolve_lease_config, + ) + + __all__ = [ + "CheckDataStream", + "Clock", + "CreditCheckDeps", + "CreditsWireClient", + "DEFAULT_LEASE_DURATION", + "DEFAULT_LEASE_SIZE", + "DEFAULT_LOW_WATER_MARK", + "DEFAULT_PREWARM_RESOLVE_TIMEOUT", + "DEFAULT_RESERVATION_TTL", + "DEFAULT_SWEEP_INTERVAL", + "InMemoryLeaseStore", + "InMemoryReservationStore", + "LeaseConfig", + "LeaseConfigOverride", + "LeaseGrant", + "LeaseManager", + "LeaseState", + "LeaseStore", + "LeaseWireClient", + "RedisLeaseStore", + "RedisReservationStore", + "ReservationConsumeResult", + "ReservationRecord", + "ReservationStore", + "ResolvedLeaseConfig", + "build_reservation_track_event", + "check_with_lease", + "consume_reservation_and_build_event", + "is_valid_quantity", + "lease_key", + "resolve_lease_config", + "settled_quantity", + ] + src/schematic/leases/check.py: | + """The client-mode check flow: probe the entitlement, lease, reserve, gate. + + One ``check()`` with usage runs the rules engine twice. The first run is a + probe against the company's real balance that names the credit being metered; + the second gates the call against the lease's local balance, after the credits + have already been debited. The conformance vectors in ``conformance/vectors`` + pin every step, and ``conformance/SPEC.md`` explains why each one is ordered + the way it is. + """ + + from __future__ import annotations + + import logging + import time + import uuid + from dataclasses import dataclass + from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Optional, Protocol + + from ..types import ( + EventBodyFlagCheck, + RulesengineCheckFlagResult, + RulesengineCompany, + RulesengineFlag, + RulesengineUser, + ) + from .lease_manager import LeaseManager + from .lease_store import LeaseStore + from .reservation_store import ReservationStore + from .types import Clock, ReservationRecord, is_valid_quantity + + if TYPE_CHECKING: + from ..client import CheckFlagOptions, CheckOptions, CheckResult, Reservation + + logger = logging.getLogger(__name__) + + # The balance the fail-open evaluation substitutes for the metered credit: + # large enough that the credit gate always passes, and still exact as a JSON + # number, so the engine reads back what the SDK sent. This is Node's + # Number.MAX_SAFE_INTEGER, which the vectors name "max_safe_integer". + FAIL_OPEN_BALANCE = 2**53 - 1 + + + class CheckDataStream(Protocol): + """The slice of ``DataStreamClient`` a lease-bearing check touches. + + Narrow on purpose: it keeps this module off the DataStream client's wider + surface, and lets the conformance runner script the flow without a socket. + """ + + async def get_flag(self, flag_key: str) -> Optional[RulesengineFlag]: ... + + async def get_company(self, keys: Dict[str, str]) -> RulesengineCompany: ... + + async def get_user(self, keys: Dict[str, str]) -> Any: ... + + def evaluate_flag( + self, flag: Any, company: Any, user: Any, options: Any = None + ) -> RulesengineCheckFlagResult: ... + + + @dataclass + class CreditCheckDeps: + """Everything a lease-bearing check draws on, gathered by the client.""" + + datastream: Optional[CheckDataStream] + lease_store: LeaseStore + reservations: ReservationStore + manager: LeaseManager + logger: logging.Logger + # Report a flag_check event for a check this module resolved itself. The + # plain check paths enqueue one per check, so without this a lease-gated + # check would be invisible to flag-check analytics and company last-seen. + # Fallback exits do not call it: the plain check they delegate to reports + # its own. + enqueue_flag_check: Callable[[EventBodyFlagCheck], Awaitable[None]] + clock: Clock = time.time + + + async def check_with_lease( + deps: CreditCheckDeps, + flag_key: str, + company: Optional[Dict[str, str]], + user: Optional[Dict[str, str]], + options: "CheckOptions", + fallback: Callable[[], Awaitable["CheckResult"]], + ) -> "CheckResult": + """Gate one check against a local lease, returning a hold when it allows. + + ``fallback`` is the plain flag check. Every step that cannot resolve a + credit to meter defers to it, since the plain check has its own degradation + story and issues no hold. A step that *can* resolve the credit but cannot + gate on it goes through ``on_acquire_failure`` instead. + """ + log = deps.logger + mode = options.on_acquire_failure or "fail-closed" + usage = options.usage + + # A malformed usage must never reach the stores, and the caller asked for a + # contract for exactly this case, so resolve it through that rather than + # letting it surface as an opaque reserve failure. + if usage is None or not is_valid_quantity(usage): + log.error( + f"Lease check: invalid usage {usage!r} for flag {flag_key}; must be a finite, non-negative number" + ) + return await _emit_flag_check( + deps, company, user, _static_failure_result(mode, flag_key, "invalid_usage", None) + ) + + # Nothing to reserve. The plain check still carries the preflight, so every + # rule evaluates normally; a 0-credit handle would only be a no-op. + if usage == 0: + log.debug(f"Lease check: usage is 0 for flag {flag_key}, nothing to reserve, using a plain check") + return await fallback() + + datastream = deps.datastream + if datastream is None: + log.debug("Lease check: no DataStream, using a plain check") + return await fallback() + + flag = await _load_flag(datastream, flag_key, log) + if flag is None: + log.debug(f"Lease check: no cached flag for {flag_key}, using a plain check") + return await fallback() + + if not company: + log.debug("Lease check: no company keys, using a plain check") + return await fallback() + + # Resolve company and user the way a plain DataStream check does: cache + # first, then a live fetch. Evaluating without an entity the caller named + # would silently skip its targeted rules and overrides, so a miss defers to + # the plain check instead. + resolved_company = await _load_company(datastream, company, log) + if resolved_company is None: + return await fallback() + + resolved_user: Optional[RulesengineUser] = None + if user: + resolved_user = await _load_user(datastream, user, log) + if resolved_user is None: + return await fallback() + + # Entitlement-first resolution. The probe runs against the real balance + # with no preflight: applying a credit cost to a lease-depleted server + # balance could fail the credit condition, drop the engine to a + # lower-priority rule, and hide the entitlement being looked for. + try: + probe = datastream.evaluate_flag(flag, resolved_company, resolved_user, None) + except Exception as err: + # A probe failure is a resolution miss, not the gate, and no hold + # exists yet to cancel. + log.warning(f"Lease check: entitlement probe failed for flag {flag_key} ({err}), using a plain check") + return await fallback() + + entitlement = probe.entitlement + if entitlement is None or entitlement.value_type != "credit": + value_type = entitlement.value_type if entitlement is not None else "" + log.debug( + f"Lease check: flag {flag_key} matched a non-credit entitlement (value_type={value_type}), " + "using a plain check, no reservation" + ) + return await fallback() + + credit_id = entitlement.credit_id + consumption_rate = entitlement.consumption_rate or 0.0 + # The caller's subtype wins; otherwise the entitlement names the metered + # event. A credit entitlement with neither a resolvable subtype nor a + # positive rate can never be billed, so it is not gateable. + event_subtype = options.event_subtype or entitlement.event_subtype + if not credit_id or consumption_rate <= 0 or not event_subtype: + log.debug( + f"Lease check: flag {flag_key} has an incomplete credit entitlement " + f"(credit_id={credit_id or ''}, consumption_rate={consumption_rate}, " + f"event_subtype={event_subtype or ''}), using a plain check" + ) + return await fallback() + + credit_cost = usage * consumption_rate + + async def failure(reason: str) -> "CheckResult": + result = await _handle_lease_failure( + datastream=datastream, + log=log, + mode=mode, + flag_key=flag_key, + reason=reason, + flag=flag, + company=resolved_company, + user=resolved_user, + credit_id=credit_id, + options=options, + ) + return await _emit_flag_check( + deps, + company, + user, + result, + company_id=resolved_company.id, + user_id=resolved_user.id if resolved_user is not None else None, + ) + + # The caller's per-check timeout governs the lease wire calls, the same way + # it governs the plain check's. + lease = await deps.manager.acquire_if_needed(resolved_company.id, credit_id, options.timeout) + if lease is None: + return await failure("lease_acquire_failed") + + # try_reserve is the atomic gate: check and debit in one step, returning + # the post-debit balance so the pre-debit figure needs no second read. + try: + post_reserve_balance = await deps.lease_store.try_reserve(resolved_company.id, credit_id, credit_cost) + if post_reserve_balance is None: + # Pass the cost as required_credits so a single large request + # extends even while the ratio sits above the water mark. + await deps.manager.maybe_extend(resolved_company.id, credit_id, credit_cost, options.timeout) + post_reserve_balance = await deps.lease_store.try_reserve(resolved_company.id, credit_id, credit_cost) + except Exception as err: + log.error(f"Lease check: reserve against {resolved_company.id}/{credit_id} failed: {err}") + return await failure("lease_store_error") + if post_reserve_balance is None: + return await failure("insufficient_lease_balance") + + # Record the hold after the debit and before the gate. A crash between the + # debit and this add leaks at most this one hold, reclaimed when the lease + # expires server-side; recording first would instead leave a record with no + # debit, which a later consume would refund into a double-spend. + resolved_config = deps.manager.resolve_config(credit_id) + record = ReservationRecord( + id=str(uuid.uuid4()), + lease_id=lease.lease_id, + company_id=resolved_company.id, + credit_type_id=credit_id, + event_subtype=event_subtype, + quantity_reserved=usage, + credits_reserved=credit_cost, + consumption_rate=consumption_rate, + expires_at=deps.clock() + resolved_config.reservation_ttl, + company=company, + user=user, + ) + try: + await deps.reservations.add(record) + except Exception as err: + log.error(f"Lease check: failed to persist reservation {record.id}: {err}") + # Undo the debit rather than strand it until lease expiry. consume + # claims whatever slice of the add landed and refunds it; a None says + # nothing landed, so refund the debit directly. Both are pinned to this + # lease. If the undo itself fails, accept the bounded leak: the slice + # comes back at lease expiry, which beats risking a double refund. + try: + if await deps.reservations.consume(record.id, 0) is None: + await deps.lease_store.refund(resolved_company.id, credit_id, credit_cost, lease.lease_id) + except Exception as undo_err: + log.warning( + f"Lease check: could not undo the local debit for {record.id} ({undo_err}); " + "the slice is reclaimed at lease expiry" + ) + return await failure("lease_store_error") + + # Gate against the lease's local view rather than the server's balance. The + # substituted figure is the PRE-reservation balance (what try_reserve + # returned plus what it debited, exact as of the debit), and credit_cost + # tells the engine what this call costs, so it evaluates the same + # arithmetic try_reserve just enforced, plus every non-credit rule. + pre_reservation = post_reserve_balance + credit_cost + substituted = _substitute_credit_balance(resolved_company, credit_id, pre_reservation) + try: + result = datastream.evaluate_flag( + flag, substituted, resolved_user, _credit_cost_options(credit_id, credit_cost) + ) + except Exception as err: + log.error(f"Lease check: rules evaluation failed for flag {flag_key}: {err}") + # The engine itself is down, so there is no fail-open re-evaluation to + # run: resolve the mode statically. + await _cancel_reservation(deps.reservations, record, log) + return await _emit_flag_check( + deps, + company, + user, + _static_failure_result(mode, flag_key, f"wasm_error: {err}", flag), + company_id=resolved_company.id, + user_id=resolved_user.id if resolved_user is not None else None, + ) + + # Engine-evaluated exits report the engine's resolved ids, mirroring the + # plain DataStream path's flag_check event. + engine_company_id = result.company_id or resolved_company.id + engine_user_id = result.user_id or (resolved_user.id if resolved_user is not None else None) + + if not result.value: + await _cancel_reservation(deps.reservations, record, log) + return await _emit_flag_check( + deps, + company, + user, + _check_result( + allowed=False, + value=False, + reason=result.reason or "denied_by_engine", + flag_key=result.flag_key or flag_key, + entitlement=_entitlement(result), + flag_id=result.flag_id, + ), + company_id=engine_company_id, + user_id=engine_user_id, + rule_id=result.rule_id, + ) + + # Allowed against the substituted balance, so the hold stands. Top the + # lease up in the background now that it has been drawn down. + deps.manager.extend_in_background(resolved_company.id, credit_id) + return await _emit_flag_check( + deps, + company, + user, + _check_result( + allowed=True, + value=True, + reason=result.reason or "lease_reserved", + flag_key=result.flag_key or flag_key, + reservation=_public_reservation(record), + entitlement=_entitlement(result), + flag_id=result.flag_id, + ), + company_id=engine_company_id, + user_id=engine_user_id, + rule_id=result.rule_id, + ) + + + async def _emit_flag_check( + deps: CreditCheckDeps, + req_company: Optional[Dict[str, str]], + req_user: Optional[Dict[str, str]], + result: "CheckResult", + *, + company_id: Optional[str] = None, + user_id: Optional[str] = None, + rule_id: Optional[str] = None, + ) -> "CheckResult": + """Report a lease-path resolution and pass the result straight through. + + Analytics must never change a verdict the caller is already acting on, so a + failure here is logged and swallowed. + """ + try: + await deps.enqueue_flag_check( + EventBodyFlagCheck( + flag_key=result.flag_key, + value=result.value, + reason=result.reason, + error=result.error, + flag_id=result.flag_id, + company_id=company_id, + user_id=user_id, + rule_id=rule_id, + req_company=req_company, + req_user=req_user, + ) + ) + except Exception as err: + deps.logger.debug(f"Lease check: failed to report the flag_check event: {err}") + return result + + + async def _load_flag(datastream: CheckDataStream, flag_key: str, log: logging.Logger) -> Optional[RulesengineFlag]: + try: + return await datastream.get_flag(flag_key) + except Exception as err: + log.warning(f"Lease check: failed to load flag {flag_key}: {err}") + return None + + + async def _load_company( + datastream: CheckDataStream, keys: Dict[str, str], log: logging.Logger + ) -> Optional[RulesengineCompany]: + try: + return await datastream.get_company(keys) + except Exception as err: + log.debug(f"Lease check: company fetch failed for keys {keys} ({err}), using a plain check") + return None + + + async def _load_user( + datastream: CheckDataStream, keys: Dict[str, str], log: logging.Logger + ) -> Optional[RulesengineUser]: + try: + return await datastream.get_user(keys) + except Exception as err: + log.debug(f"Lease check: user fetch failed for keys {keys} ({err}), using a plain check") + return None + + + async def _handle_lease_failure( + *, + datastream: CheckDataStream, + log: logging.Logger, + mode: str, + flag_key: str, + reason: str, + flag: RulesengineFlag, + company: RulesengineCompany, + user: Optional[RulesengineUser], + credit_id: str, + options: "CheckOptions", + ) -> "CheckResult": + """Resolve a check that could not gate: acquire failed, store unreachable, + or the lease is exhausted. + + fail-closed denies. fail-open means assume the credits are there, not skip + the evaluation: the rules still run with the balance substituted to an + effectively unlimited value, so plan targeting, overrides, and every + non-credit condition still apply, and a company that is not entitled stays + denied with the lease backend down. Only an error in that evaluation drops + to a blanket allow. + """ + if mode == "fail-closed": + return _static_failure_result(mode, flag_key, reason, flag) + + try: + substituted = _substitute_credit_balance(company, credit_id, FAIL_OPEN_BALANCE) + result = datastream.evaluate_flag(flag, substituted, user, _preflight_options(options)) + except Exception as err: + log.warning(f"Lease check: the fail-open evaluation failed ({err}); allowing") + return _static_failure_result(mode, flag_key, reason, flag) + return _check_result( + allowed=result.value, + value=result.value, + reason=f"{result.reason or 'evaluated'} ({reason}_fail_open)", + flag_key=result.flag_key or flag_key, + entitlement=_entitlement(result), + flag_id=result.flag_id or flag.id, + error=reason, + ) + + + def _static_failure_result( + mode: str, flag_key: str, reason: str, flag: Optional[RulesengineFlag] + ) -> "CheckResult": + """Resolve a mode with no evaluation behind it: deny for fail-closed, + blanket allow for fail-open. + + Used when the engine is the thing that failed, and when the fail-open + evaluation itself errors. + """ + allowed = mode != "fail-closed" + return _check_result( + allowed=allowed, + value=allowed, + reason=f"{reason}_fail_open" if allowed else reason, + flag_key=flag_key, + flag_id=flag.id if flag is not None else None, + error=reason, + ) + + + async def _cancel_reservation( + reservations: ReservationStore, record: ReservationRecord, log: logging.Logger + ) -> None: + """Claim the hold and refund all of it. Best effort: a failure leaves the + hold for the sweeper or for lease expiry.""" + try: + await reservations.consume(record.id, 0) + except Exception as err: + log.warning( + f"Lease check: failed to cancel reservation {record.id} ({err}); " + "its hold is reclaimed by the sweeper or at lease expiry" + ) + + + def _substitute_credit_balance( + company: RulesengineCompany, credit_id: str, balance: float + ) -> RulesengineCompany: + balances = dict(company.credit_balances or {}) + balances[credit_id] = balance + return company.model_copy(update={"credit_balances": balances}) + + + def _credit_cost_options(credit_id: str, credit_cost: float) -> "CheckFlagOptions": + from ..client import CheckFlagOptions + + return CheckFlagOptions(credit_cost={credit_id: credit_cost}) + + + def _preflight_options(options: "CheckOptions") -> Optional["CheckFlagOptions"]: + from ..client import _check_options_to_flag_options + + return _check_options_to_flag_options(options) + + + def _entitlement(result: RulesengineCheckFlagResult) -> Optional[Any]: + if result.entitlement is None: + return None + from ..types import FeatureEntitlement + + return FeatureEntitlement.model_validate(result.entitlement.model_dump()) + + + def _public_reservation(record: ReservationRecord) -> "Reservation": + """The caller's handle on a hold carved out of a lease.""" + import datetime as dt + + from ..client import Reservation + + return Reservation( + id=record.id, + lease_id=record.lease_id, + mode="client", + company_id=record.company_id, + credit_type_id=record.credit_type_id, + event_subtype=record.event_subtype, + quantity_reserved=record.quantity_reserved, + credits_reserved=record.credits_reserved, + consumption_rate=record.consumption_rate, + expires_at=dt.datetime.fromtimestamp(record.expires_at, tz=dt.timezone.utc), + company=record.company, + user=record.user, + ) + + + def _check_result(**fields: Any) -> "CheckResult": + """CheckResult is defined on the client, which imports this module, so the + import waits until call time to keep the cycle from closing at import.""" + from ..client import CheckResult + + return CheckResult(**fields) + src/schematic/leases/lease_manager.py: | + """Lease lifecycle against the server: acquire, extend, release, sweep. + + Every path here resolves rather than raises. The manager's callers route a + missing lease through their fail-open/fail-closed handling, and several calls + are made fire-and-forget, where a raised exception would surface as an + unretrieved task exception instead. + """ + + from __future__ import annotations + + import asyncio + import datetime as dt + import logging + import time + from dataclasses import dataclass + from typing import Any, Awaitable, Dict, Optional, Protocol, Set + + from .lease_store import LeaseStore, lease_key + from .reservation_store import ReservationStore + from .types import ( + DEFAULT_SWEEP_INTERVAL, + Clock, + LeaseConfig, + LeaseState, + ResolvedLeaseConfig, + resolve_lease_config, + ) + + logger = logging.getLogger(__name__) + + + @dataclass + class LeaseGrant: + """What the server says a lease is, after an acquire or an extend.""" + + lease_id: str + company_id: str + credit_type_id: str + # The server-authoritative TOTAL, not the increment an extend asked for. + granted_amount: float + expires_at: float + + + class LeaseWireClient(Protocol): + """The three lease calls the manager makes. + + Narrow on purpose: it keeps the manager independent of the generated + client's request and response models, and lets tests script the server. + """ + + async def acquire( + self, + company_id: str, + credit_type_id: str, + requested_amount: float, + expires_at: float, + timeout: Optional[float] = None, + ) -> LeaseGrant: ... + + async def extend( + self, + lease_id: str, + additional_amount: float, + expires_at: float, + timeout: Optional[float] = None, + ) -> LeaseGrant: ... + + async def release(self, lease_id: str) -> None: ... + + + class CreditsWireClient: + """Adapter over the generated async credits client (``AsyncCreditsClient``).""" + + def __init__(self, credits_client: Any, *, request_options: Optional[Any] = None) -> None: + self._credits = credits_client + self._request_options = request_options + + def _options(self, timeout: Optional[float]) -> Optional[Any]: + """The caller's per-check timeout wins over the client-wide options, + which is what a caller asking for one on this check means.""" + if timeout is None: + return self._request_options + return {"timeout": timeout} + + async def acquire( + self, + company_id: str, + credit_type_id: str, + requested_amount: float, + expires_at: float, + timeout: Optional[float] = None, + ) -> LeaseGrant: + response = await self._credits.acquire_credit_lease( + company_id=company_id, + credit_type_id=credit_type_id, + requested_amount=requested_amount, + expires_at=_to_datetime(expires_at), + request_options=self._options(timeout), + ) + return _grant_from_response(response) + + async def extend( + self, + lease_id: str, + additional_amount: float, + expires_at: float, + timeout: Optional[float] = None, + ) -> LeaseGrant: + response = await self._credits.extend_credit_lease( + lease_id, + additional_amount=additional_amount, + expires_at=_to_datetime(expires_at), + request_options=self._options(timeout), + ) + return _grant_from_response(response) + + async def release(self, lease_id: str) -> None: + await self._credits.release_credit_lease(lease_id, request_options=self._request_options) + + + class LeaseManager: + """Owns lease rows for one client: acquire on first use or after expiry, + extend when the local view dips below the water mark, release on close. + + Acquire and extend each get their own best-effort single-flight map keyed + by slot. Best-effort because callers racing ahead of the registration can + still issue duplicate wire calls, which is safe: the server is idempotent + for an active slot, ``replace`` keeps the first live lease, and ``extend`` + reconciles to a total. + """ + + def __init__( + self, + wire_client: LeaseWireClient, + lease_store: LeaseStore, + *, + reservation_store: Optional[ReservationStore] = None, + config: Optional[LeaseConfig] = None, + clock: Clock = time.time, + ) -> None: + self._wire = wire_client + self._lease_store = lease_store + self._reservation_store = reservation_store + self._config = config or LeaseConfig() + self._clock = clock + # Kept separate so an in-flight extend can never satisfy an acquire, + # or the other way round. + self._inflight_acquire: Dict[str, "asyncio.Future[Optional[LeaseState]]"] = {} + self._inflight_extend: Dict[str, "asyncio.Future[Optional[LeaseState]]"] = {} + self._background: Set["asyncio.Task[None]"] = set() + self._sweep_task: Optional["asyncio.Task[None]"] = None + self._stopped = False + + def resolve_config(self, credit_type_id: str) -> ResolvedLeaseConfig: + return resolve_lease_config(self._config, None, credit_type_id) + + @property + def sweep_interval(self) -> float: + return self._config.sweep_interval or DEFAULT_SWEEP_INTERVAL + + async def acquire_if_needed( + self, company_id: str, credit_type_id: str, timeout: Optional[float] = None + ) -> Optional[LeaseState]: + """The slot's live lease, acquiring one over the wire if none is live. + + ``timeout`` governs the wire call this caller starts. A caller that + joins an in-flight acquire rides the first caller's timeout, since + there is one shared call to time out. + """ + try: + existing = await self._lease_store.get(company_id, credit_type_id) + except Exception as err: + logger.error("Failed to read lease store for %s/%s: %s", company_id, credit_type_id, err) + return None + if existing is not None and existing.expires_at > self._clock(): + return existing + # An expired (or absent) slot is left for `replace` to overwrite: it + # guards on expiry and writes atomically. Dropping the stale row first + # would be a separate, non-atomic op that can interleave between a + # sibling pod's read and its replace, clobbering a lease that pod just + # installed. Reading a stale entry in the gap is harmless, since every + # path that acts on a lease re-guards on expiry. + + key = lease_key(company_id, credit_type_id) + inflight = self._inflight_acquire.get(key) + if inflight is not None: + return await asyncio.shield(inflight) + return await self._single_flight( + self._inflight_acquire, key, self._acquire(company_id, credit_type_id, timeout) + ) + + async def _acquire( + self, company_id: str, credit_type_id: str, timeout: Optional[float] = None + ) -> Optional[LeaseState]: + resolved = self.resolve_config(credit_type_id) + try: + grant = await self._wire.acquire( + company_id, + credit_type_id, + resolved.lease_size, + self._clock() + resolved.lease_duration, + timeout, + ) + wrote = await self._lease_store.replace( + lease_id=grant.lease_id, + company_id=grant.company_id or company_id, + credit_type_id=grant.credit_type_id or credit_type_id, + granted_amount=grant.granted_amount, + expires_at=grant.expires_at, + ) + if wrote: + return await self._lease_store.get(company_id, credit_type_id) + + # A sibling holds the slot with a live lease, or the slot's expired + # row was reconciled in place. The server is idempotent for an + # active slot, so a racing acquire is normally handed back the SAME + # lease the sibling installed, and releasing it would pull the + # shared lease out from under every pod. Only a *different* lease + # is a redundant hold nobody will draw on, so only that one is + # released. An empty slot (expired in the gap) releases nothing + # either: this lease is likely what the next acquire is handed. + current = await self._lease_store.get(company_id, credit_type_id) + if current is not None and current.lease_id != grant.lease_id: + logger.debug( + "Lost acquire race for %s/%s; releasing redundant lease %s", + company_id, + credit_type_id, + grant.lease_id, + ) + self._spawn(self._release(grant.lease_id)) + return current + except Exception as err: + logger.error("Failed to acquire credit lease for %s/%s: %s", company_id, credit_type_id, err) + return None + + async def maybe_extend( + self, + company_id: str, + credit_type_id: str, + required_credits: Optional[float] = None, + timeout: Optional[float] = None, + ) -> Optional[LeaseState]: + """Extend the slot's lease when the local view warrants it. + + Triggered by either the low-water-mark ratio (steady-state refresh) or + a ``required_credits`` hint above the local remaining (a check just + failed a reserve of that size). + """ + try: + entry = await self._lease_store.get(company_id, credit_type_id) + except Exception as err: + logger.warning("Failed to read lease store for %s/%s: %s", company_id, credit_type_id, err) + return None + if entry is None: + return None + # Never extend an expired lease: the server treats it as released and + # has already refunded its remainder, so the only correct move is a + # fresh acquire on the next check. + if entry.expires_at <= self._clock(): + return None + resolved = self.resolve_config(credit_type_id) + ratio = entry.local_remaining_credits / max(entry.granted_amount, 1) + below_watermark = ratio <= resolved.low_water_mark + below_required = required_credits is not None and entry.local_remaining_credits < required_credits + if not below_watermark and not below_required: + return entry + + key = lease_key(company_id, credit_type_id) + inflight = self._inflight_extend.get(key) + if inflight is not None: + return await asyncio.shield(inflight) + return await self._single_flight( + self._inflight_extend, key, self._extend(entry, resolved, required_credits, timeout) + ) + + async def _extend( + self, + entry: LeaseState, + resolved: ResolvedLeaseConfig, + required_credits: Optional[float], + timeout: Optional[float] = None, + ) -> Optional[LeaseState]: + # Size the extend to cover the request that triggered it: a single + # check needing more than remaining plus one tranche would otherwise + # fail its post-extend retry forever, however much balance the server + # has. The steady-state path keeps asking for the configured tranche. + shortfall = (required_credits - entry.local_remaining_credits) if required_credits is not None else 0.0 + try: + grant = await self._wire.extend( + entry.lease_id, + max(resolved.lease_size, shortfall), + self._clock() + resolved.lease_duration, + timeout, + ) + # Reconcile to the server's authoritative TOTAL, with the store + # computing the delta against its own current total: per-process + # single-flight does not cover sibling pods. Pinned to the lease + # the server extended, so an expiry mid-call cannot mint the delta + # onto a successor. + await self._lease_store.extend( + entry.company_id, + entry.credit_type_id, + grant.granted_amount, + grant.expires_at, + entry.lease_id, + ) + return await self._lease_store.get(entry.company_id, entry.credit_type_id) + except Exception as err: + logger.warning("Failed to extend credit lease %s: %s", entry.lease_id, err) + return None + + def extend_in_background(self, company_id: str, credit_type_id: str) -> None: + """Kick off a water-mark extend without waiting for it. + + A check that just drew the lease down should not pay for the top-up, so + the extend runs as tracked background work and never raises into the + caller. + """ + + async def run() -> None: + await self.maybe_extend(company_id, credit_type_id) + + self._spawn(run()) + + async def release_all_local_leases(self) -> None: + """Release every live lease this process exclusively holds. + + Only a per-process store answers ``list_leases``; a shared backend + returns ``None`` and is skipped, since sibling pods still draw on those + leases. Expired leases are skipped too: the server already swept them. + Best-effort, with failures falling back to server-side expiry. + """ + try: + entries = self._lease_store.list_leases() + except Exception as err: + logger.warning("Failed to enumerate leases on close: %s", err) + return + if not entries: + return + now = self._clock() + for entry in entries: + if entry.expires_at <= now: + continue + try: + await self._wire.release(entry.lease_id) + await self._lease_store.drop(entry.company_id, entry.credit_type_id) + logger.debug("Released credit lease %s on close", entry.lease_id) + except Exception as err: + logger.warning( + "Failed to release credit lease %s on close (it will expire server-side): %s", + entry.lease_id, + err, + ) + + def start_sweep(self) -> None: + """Run the expired-reservation sweep on an interval. Safe to call twice.""" + if self._reservation_store is None or self._sweep_task is not None or self._stopped: + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + logger.debug("No running event loop; the reservation sweep stays off") + return + self._sweep_task = loop.create_task(self._sweep_loop()) + + async def _sweep_loop(self) -> None: + store = self._reservation_store + assert store is not None + while True: + await asyncio.sleep(self.sweep_interval) + try: + await store.sweep_expired() + except asyncio.CancelledError: + raise + except Exception as err: + # Keep the loop alive: a sweep failure is transient (a Redis + # blip), and the next tick retries. + logger.debug("Reservation sweep failed: %s", err) + + def stop(self) -> None: + """Cancel the sweep loop. Pending releases are left to finish.""" + self._stopped = True + if self._sweep_task is not None: + self._sweep_task.cancel() + self._sweep_task = None + + async def _single_flight( + self, + registry: Dict[str, "asyncio.Future[Optional[LeaseState]]"], + key: str, + coro: Awaitable[Optional[LeaseState]], + ) -> Optional[LeaseState]: + task = asyncio.ensure_future(coro) + registry[key] = task + try: + return await asyncio.shield(task) + finally: + if registry.get(key) is task: + del registry[key] + + async def _release(self, lease_id: str) -> None: + try: + await self._wire.release(lease_id) + except Exception as err: + logger.warning("Failed to release redundant credit lease %s: %s", lease_id, err) + + def _spawn(self, coro: Awaitable[None]) -> None: + """Run a fire-and-forget step, holding a reference so it is not collected.""" + try: + task = asyncio.get_running_loop().create_task(_never_raises(coro)) + except RuntimeError: + logger.debug("No running event loop; skipping background lease work") + return + self._background.add(task) + task.add_done_callback(self._background.discard) + + async def _drain_background(self) -> None: + """Wait out pending fire-and-forget work. For tests and close paths.""" + while self._background: + await asyncio.gather(*list(self._background), return_exceptions=True) + + + async def _never_raises(coro: Awaitable[None]) -> None: + try: + await coro + except asyncio.CancelledError: + raise + except Exception as err: + logger.debug("Background lease work failed: %s", err) + + + def _to_datetime(epoch_seconds: float) -> dt.datetime: + return dt.datetime.fromtimestamp(epoch_seconds, tz=dt.timezone.utc) + + + def _epoch_seconds(value: dt.datetime) -> float: + # A naive timestamp from the API is UTC; reading it as local time would + # shift every expiry by the pod's offset. + if value.tzinfo is None: + return value.replace(tzinfo=dt.timezone.utc).timestamp() + return value.timestamp() + + + def _grant_from_response(response: Any) -> LeaseGrant: + data = response.data + return LeaseGrant( + lease_id=data.id, + company_id=data.company_id, + credit_type_id=data.credit_type_id, + granted_amount=float(data.granted_amount), + expires_at=_epoch_seconds(data.expires_at), + ) + src/schematic/leases/lease_store.py: | + """Lease slot storage: the contract, plus the per-process in-memory backend. + + At most one lease occupies a ``(company_id, credit_type_id)`` slot. Every + mutation is atomic per slot; ``RedisLeaseStore`` gets that from single-key Lua, + this one from a per-slot ``asyncio.Lock``. + """ + + from __future__ import annotations + + import abc + import asyncio + import math + import time + from contextlib import asynccontextmanager + from typing import AsyncIterator, Dict, List, Optional, Tuple + + from .types import Clock, LeaseState + + + def lease_key(company_id: str, credit_type_id: str) -> str: + return f"{company_id}:{credit_type_id}" + + + class LeaseStore(abc.ABC): + """Backing store for lease slots, shared by the in-memory and Redis backends.""" + + @abc.abstractmethod + async def get(self, company_id: str, credit_type_id: str) -> Optional[LeaseState]: + """Snapshot of the slot, expired or not. Callers re-guard on expiry.""" + + @abc.abstractmethod + async def replace( + self, + *, + lease_id: str, + company_id: str, + credit_type_id: str, + granted_amount: float, + expires_at: float, + ) -> bool: + """Install a fresh lease at its full grant, if the slot is free to take. + + A *live* lease holds the slot even when it carries a different id (a + sibling pod won the acquire race): its already-debited balance wins and + this reports ``False``. An *expired* row carrying the SAME id is not + rewritten either, since that would reset the balance and erase debits + whose reservations are still open; it is reconciled like an extend + (granted to the incoming total, expiry forward only, balance untouched) + and also reports ``False``. Returns ``True`` only when a fresh row was + written, which is what tells the manager whether the lease it just + acquired is redundant. + """ + + @abc.abstractmethod + async def try_reserve(self, company_id: str, credit_type_id: str, credits: float) -> Optional[float]: + """Atomically check and debit, returning the post-debit balance. + + ``None`` when there is no lease, it has expired, the balance is short, + or ``credits`` is not a finite non-negative number. Returning the + balance (rather than a bool) lets the caller derive the pre-debit + figure as ``returned + credits`` without a racy follow-up read. + """ + + @abc.abstractmethod + async def refund( + self, + company_id: str, + credit_type_id: str, + credits: float, + pin_lease_id: Optional[str] = None, + ) -> None: + """Return credits to the slot's balance, clamped at the granted amount. + + With ``pin_lease_id``, the refund applies only while the slot still + holds that lease: a hold carved out of an expired lease must never + inflate its successor, whose grant the server already issued whole. + """ + + @abc.abstractmethod + async def extend( + self, + company_id: str, + credit_type_id: str, + granted_total: float, + new_expires_at: Optional[float] = None, + pin_lease_id: Optional[str] = None, + ) -> None: + """Reconcile the slot to the server-authoritative total. + + The delta is computed inside the store against the currently stored + total, never from a caller-held pre-wire-call read: two pods extending + concurrently from the same stale read would each apply a delta and mint + phantom credits. A total a sibling already applied is a no-op, so + applies converge in any order. Expiry only ever moves forward. + """ + + @abc.abstractmethod + async def drop(self, company_id: str, credit_type_id: str) -> None: + """Remove the slot entry, after a remote release.""" + + def list_leases(self) -> Optional[List[LeaseState]]: + """Every lease this store holds, or ``None`` when it cannot enumerate. + + Only a per-process store answers: its leases are exclusively this + process's, so releasing them on close is safe. A shared backend must + never enumerate and release, since sibling pods still draw on those + leases. + """ + return None + + + class _SlotLocks: + """Per-slot mutual exclusion, refcounted so idle slots do not accumulate.""" + + def __init__(self) -> None: + self._locks: Dict[str, Tuple[asyncio.Lock, int]] = {} + + @asynccontextmanager + async def hold(self, key: str) -> AsyncIterator[None]: + lock, waiters = self._locks.get(key, (asyncio.Lock(), 0)) + self._locks[key] = (lock, waiters + 1) + try: + async with lock: + yield + finally: + held, count = self._locks[key] + if count <= 1: + del self._locks[key] + else: + self._locks[key] = (held, count - 1) + + + class InMemoryLeaseStore(LeaseStore): + """Per-process lease slots. Single-pod gating only. + + Swap in ``RedisLeaseStore`` to gate across pods; both implement the same + contract. + """ + + def __init__(self, *, clock: Clock = time.time) -> None: + self._clock = clock + self._leases: Dict[str, LeaseState] = {} + self._locks = _SlotLocks() + + async def get(self, company_id: str, credit_type_id: str) -> Optional[LeaseState]: + entry = self._leases.get(lease_key(company_id, credit_type_id)) + return _copy(entry) if entry else None + + async def replace( + self, + *, + lease_id: str, + company_id: str, + credit_type_id: str, + granted_amount: float, + expires_at: float, + ) -> bool: + key = lease_key(company_id, credit_type_id) + async with self._locks.hold(key): + existing = self._leases.get(key) + if existing is not None and existing.expires_at > self._clock(): + return False + if existing is not None and existing.lease_id == lease_id: + # The same lease coming back over its own expired row: a stale + # acquire response for a lease the idempotent server also + # handed a racing sibling, which may since have extended it. + add = granted_amount - existing.granted_amount + if add > 0: + existing.granted_amount = granted_amount + existing.local_remaining_credits += add + if expires_at > existing.expires_at: + existing.expires_at = expires_at + return False + self._leases[key] = LeaseState( + lease_id=lease_id, + company_id=company_id, + credit_type_id=credit_type_id, + granted_amount=granted_amount, + local_remaining_credits=granted_amount, + expires_at=expires_at, + ) + return True + + async def try_reserve(self, company_id: str, credit_type_id: str, credits: float) -> Optional[float]: + # NaN passes every comparison below, and a NaN balance would approve + # every later reserve, so it never reaches the arithmetic. + if not is_finite_non_negative(credits): + return None + key = lease_key(company_id, credit_type_id) + async with self._locks.hold(key): + entry = self._leases.get(key) + if entry is None: + return None + if entry.expires_at <= self._clock(): + return None + if entry.local_remaining_credits < credits: + return None + entry.local_remaining_credits -= credits + return entry.local_remaining_credits + + async def refund( + self, + company_id: str, + credit_type_id: str, + credits: float, + pin_lease_id: Optional[str] = None, + ) -> None: + if not is_finite_non_negative(credits) or credits <= 0: + return + key = lease_key(company_id, credit_type_id) + async with self._locks.hold(key): + entry = self._leases.get(key) + if entry is None: + return + if pin_lease_id is not None and entry.lease_id != pin_lease_id: + return + entry.local_remaining_credits = min( + entry.local_remaining_credits + credits, + entry.granted_amount, + ) + + async def extend( + self, + company_id: str, + credit_type_id: str, + granted_total: float, + new_expires_at: Optional[float] = None, + pin_lease_id: Optional[str] = None, + ) -> None: + key = lease_key(company_id, credit_type_id) + async with self._locks.hold(key): + entry = self._leases.get(key) + if entry is None: + return + if pin_lease_id is not None and entry.lease_id != pin_lease_id: + return + add = granted_total - entry.granted_amount + if add > 0: + entry.granted_amount = granted_total + entry.local_remaining_credits += add + if new_expires_at is not None and new_expires_at > entry.expires_at: + entry.expires_at = new_expires_at + + async def drop(self, company_id: str, credit_type_id: str) -> None: + key = lease_key(company_id, credit_type_id) + async with self._locks.hold(key): + self._leases.pop(key, None) + + def list_leases(self) -> Optional[List[LeaseState]]: + return [_copy(entry) for entry in self._leases.values()] + + + def _copy(entry: LeaseState) -> LeaseState: + return LeaseState( + lease_id=entry.lease_id, + company_id=entry.company_id, + credit_type_id=entry.credit_type_id, + granted_amount=entry.granted_amount, + local_remaining_credits=entry.local_remaining_credits, + expires_at=entry.expires_at, + ) + + + def is_finite_non_negative(value: float) -> bool: + """A credit amount fit for arithmetic: finite, and not negative.""" + try: + number = float(value) + except (TypeError, ValueError): + return False + return math.isfinite(number) and number >= 0 + src/schematic/leases/redis_lease_store.py: | + """Redis-backed lease slots: one hash per slot, mutated by single-key Lua. + + The key layout, hash field names (camelCase), millisecond instants, Lua + scripts, and TTL grace windows match the Node SDK exactly, so Node and Python + pods can share one Redis and agree on every slot. + """ + + from __future__ import annotations + + import time + from typing import Any, Dict, List, Optional + + from .lease_store import LeaseStore, is_finite_non_negative, lease_key + from .types import DEFAULT_LEASE_DURATION, Clock, LeaseState + + DEFAULT_KEY_PREFIX = "schematic:" + LEASE_KEY_NAMESPACE = "credit-lease:" + # How long after the declared expiry the row survives before Redis evicts it. + # Gives the sweeper a window to refund expired reservations before the lease + # state underneath them disappears. + LEASE_TTL_GRACE_MS = 60_000 + + # Every Lua script below touches exactly ONE key (the lease hash), keeping + # them safe under Redis Cluster (multi-key scripts spanning slots raise + # CROSSSLOT). Only the lease hash needs atomic mutation; cross-key + # bookkeeping uses ordinary single-key commands. + # + # Expiry is decided against the *Redis server's* clock (`redis.call('TIME')`), + # not the calling pod's: with many pods sharing one lease, local clock skew + # would let pods disagree on whether the lease is live. The `LEASE_NOW_MS` + # snippet converts TIME to integer milliseconds (matching the stored + # `expiresAt`); `redis.replicate_commands()` first, so the non-deterministic + # TIME read is allowed alongside writes on Redis 5/6. + LEASE_NOW_MS = """ + redis.replicate_commands() + local t = redis.call('TIME') + local now = (tonumber(t[1]) * 1000) + math.floor(tonumber(t[2]) / 1000) + """ + + # Atomic `replace`. Writes the lease hash only when the slot is empty or the + # existing lease has expired. Returns 1 on write, 0 if a *live* lease already + # occupies the slot, even one with a different leaseId, e.g. installed by a + # sibling instance that raced this acquire. An expired row with the SAME + # leaseId is reconciled like an extend instead of rewritten, which would reset + # the balance and erase debits whose reservations are still open. + REPLACE_SCRIPT = ( + LEASE_NOW_MS + + """ + local existing_id = redis.call('HGET', KEYS[1], 'leaseId') + local existing_expiry = tonumber(redis.call('HGET', KEYS[1], 'expiresAt') or '0') + local new_id = ARGV[1] + local new_granted = ARGV[2] + local new_expiry = tonumber(ARGV[3]) + local grace = tonumber(ARGV[4]) + + if existing_id and existing_expiry > now then + return 0 + end + + if existing_id == new_id then + local granted = tonumber(redis.call('HGET', KEYS[1], 'grantedAmount') or '0') + local add = tonumber(new_granted) - granted + if add > 0 then + local remaining = tonumber(redis.call('HGET', KEYS[1], 'localRemainingCredits') or '0') + redis.call('HSET', KEYS[1], + 'grantedAmount', new_granted, + 'localRemainingCredits', tostring(remaining + add)) + end + if new_expiry > existing_expiry then + redis.call('HSET', KEYS[1], 'expiresAt', ARGV[3]) + redis.call('PEXPIREAT', KEYS[1], new_expiry + grace) + end + return 0 + end + + redis.call('DEL', KEYS[1]) + redis.call('HSET', KEYS[1], + 'leaseId', new_id, + 'companyId', ARGV[5], + 'creditTypeId', ARGV[6], + 'grantedAmount', new_granted, + 'localRemainingCredits', new_granted, + 'expiresAt', ARGV[3]) + redis.call('PEXPIREAT', KEYS[1], new_expiry + grace) + return 1 + """ + ) + + # Atomic check-and-decrement on `localRemainingCredits`. Returns the post-debit + # balance as a string (a Lua number reply truncates to integer, which would + # corrupt fractional credit costs); nil if there is no lease, the lease has + # expired, or there is insufficient remaining. The expiry guard compares + # against the Redis server clock, so a reserve against an expired-but-not-yet- + # evicted row during the TTL grace window is rejected. + TRY_RESERVE_SCRIPT = ( + LEASE_NOW_MS + + """ + local raw = redis.call('HGET', KEYS[1], 'localRemainingCredits') + if not raw then return false end + local expiry = tonumber(redis.call('HGET', KEYS[1], 'expiresAt') or '0') + if expiry <= now then return false end + local remaining = tonumber(raw) + local requested = tonumber(ARGV[1]) + if remaining < requested then return false end + local new_remaining = remaining - requested + redis.call('HSET', KEYS[1], 'localRemainingCredits', tostring(new_remaining)) + return tostring(new_remaining) + """ + ) + + # Refund credits, clamped at `grantedAmount`. ARGV[2], when non-empty, pins the + # refund to a specific leaseId: if the slot now holds a different lease, the + # refund is dropped: the expired lease's unspent remainder was already + # returned to the company balance server-side, so crediting the successor would + # mint phantom credits. + REFUND_SCRIPT = """ + local raw_remaining = redis.call('HGET', KEYS[1], 'localRemainingCredits') + if not raw_remaining then return 0 end + local required_lease = ARGV[2] + if required_lease and required_lease ~= '' then + local current_lease = redis.call('HGET', KEYS[1], 'leaseId') + if current_lease ~= required_lease then return 0 end + end + local remaining = tonumber(raw_remaining) + local granted = tonumber(redis.call('HGET', KEYS[1], 'grantedAmount') or '0') + local refund = tonumber(ARGV[1]) + local new_balance = remaining + refund + if new_balance > granted then new_balance = granted end + redis.call('HSET', KEYS[1], 'localRemainingCredits', tostring(new_balance)) + return 1 + """ + + # Reconcile the lease to the server-authoritative grantedAmount total + # (ARGV[1]), crediting the difference to localRemainingCredits. The delta is + # computed HERE, atomically against the hash's current total, never by the + # caller from a pre-wire-call read: two pods extending the same shared lease + # concurrently would each apply a delta against the same stale read and mint + # phantom credits. Reconciling to the absolute total converges regardless of + # arrival order. Expiry only ever moves forward. ARGV[4], when non-empty, pins + # the extend to a leaseId, mirroring REFUND_SCRIPT. + EXTEND_SCRIPT = """ + local raw_granted = redis.call('HGET', KEYS[1], 'grantedAmount') + if not raw_granted then return 0 end + local required_lease = ARGV[4] + if required_lease and required_lease ~= '' then + local current_lease = redis.call('HGET', KEYS[1], 'leaseId') + if current_lease ~= required_lease then return 0 end + end + local granted = tonumber(raw_granted) + local target = tonumber(ARGV[1]) + local add = target - granted + if add > 0 then + local remaining = tonumber(redis.call('HGET', KEYS[1], 'localRemainingCredits') or '0') + redis.call('HSET', KEYS[1], + 'grantedAmount', tostring(target), + 'localRemainingCredits', tostring(remaining + add)) + end + local new_expiry = tonumber(ARGV[2]) + local grace = tonumber(ARGV[3]) + local current_expiry = tonumber(redis.call('HGET', KEYS[1], 'expiresAt') or '0') + if new_expiry > current_expiry then + redis.call('HSET', KEYS[1], 'expiresAt', ARGV[2]) + redis.call('PEXPIREAT', KEYS[1], new_expiry + grace) + end + return 1 + """ + + + class LuaScript: + """One Lua script, run by EVALSHA with a lazy SCRIPT LOAD and an EVAL fallback. + + A Redis that has dropped its script cache (restart, SCRIPT FLUSH) answers + NOSCRIPT; the fallback re-sends the body and re-loads it on the next call. + """ + + def __init__(self, body: str) -> None: + self.body = body + self._sha: Optional[str] = None + + async def run(self, client: Any, keys: List[str], args: List[str]) -> Any: + if self._sha is None: + self._sha = to_str(await client.script_load(self.body)) + try: + return await client.evalsha(self._sha, len(keys), *keys, *args) + except Exception as err: + if not _is_noscript(err): + raise + self._sha = None + return await client.eval(self.body, len(keys), *keys, *args) + + + class RedisLeaseStore(LeaseStore): + """Lease slots in Redis: one hash per slot, atomic via single-key Lua. + + ``client`` is a connected ``redis.asyncio.Redis``. Balances are stored as + strings so fractional credit amounts survive the round trip. + """ + + def __init__( + self, + client: Any, + *, + key_prefix: str = DEFAULT_KEY_PREFIX, + default_lease_duration: float = DEFAULT_LEASE_DURATION, + clock: Clock = time.time, + ) -> None: + self._client = client + self._key_prefix = key_prefix + # Only reached when a direct caller extends without an expiry; the + # lease manager always passes one. + self._default_lease_duration = default_lease_duration + self._clock = clock + self._replace = LuaScript(REPLACE_SCRIPT) + self._try_reserve = LuaScript(TRY_RESERVE_SCRIPT) + self._refund = LuaScript(REFUND_SCRIPT) + self._extend = LuaScript(EXTEND_SCRIPT) + + def hash_key(self, company_id: str, credit_type_id: str) -> str: + """Public so the reservation store can target the same lease hash.""" + return f"{self._key_prefix}{LEASE_KEY_NAMESPACE}{lease_key(company_id, credit_type_id)}" + + async def get(self, company_id: str, credit_type_id: str) -> Optional[LeaseState]: + raw = decode_hash(await self._client.hgetall(self.hash_key(company_id, credit_type_id))) + if not raw.get("leaseId"): + return None + return LeaseState( + lease_id=raw["leaseId"], + company_id=raw.get("companyId", company_id), + credit_type_id=raw.get("creditTypeId", credit_type_id), + granted_amount=float(raw.get("grantedAmount", "0")), + local_remaining_credits=float(raw.get("localRemainingCredits", "0")), + expires_at=float(raw.get("expiresAt", "0")) / 1000.0, + ) + + async def replace( + self, + *, + lease_id: str, + company_id: str, + credit_type_id: str, + granted_amount: float, + expires_at: float, + ) -> bool: + result = await self._replace.run( + self._client, + [self.hash_key(company_id, credit_type_id)], + # No client clock here: the script reads `now` from the Redis + # server via TIME, so every pod agrees on expiry. + [ + lease_id, + format_amount(granted_amount), + to_epoch_ms(expires_at), + str(LEASE_TTL_GRACE_MS), + company_id, + credit_type_id, + ], + ) + return _to_number(result) == 1 + + async def try_reserve(self, company_id: str, credit_type_id: str, credits: float) -> Optional[float]: + # Reject non-finite/negative debits before they reach the script: the + # string form of NaN parses back to a Lua nan, slips through the `<` + # comparison, and would poison the SHARED balance for every pod. + if not is_finite_non_negative(credits): + return None + result = await self._try_reserve.run( + self._client, + [self.hash_key(company_id, credit_type_id)], + [format_amount(credits)], + ) + if result is None or result is False: + return None + return float(to_str(result)) + + async def refund( + self, + company_id: str, + credit_type_id: str, + credits: float, + pin_lease_id: Optional[str] = None, + ) -> None: + if not is_finite_non_negative(credits) or credits <= 0: + return + await self._refund.run( + self._client, + [self.hash_key(company_id, credit_type_id)], + # An empty string disables the lease pin (Lua has no nil ARGV). + [format_amount(credits), pin_lease_id or ""], + ) + + async def extend( + self, + company_id: str, + credit_type_id: str, + granted_total: float, + new_expires_at: Optional[float] = None, + pin_lease_id: Optional[str] = None, + ) -> None: + expiry = new_expires_at if new_expires_at is not None else self._clock() + self._default_lease_duration + await self._extend.run( + self._client, + [self.hash_key(company_id, credit_type_id)], + # granted_total is the server-authoritative TOTAL; the script + # computes the delta against the stored total. + [format_amount(granted_total), to_epoch_ms(expiry), str(LEASE_TTL_GRACE_MS), pin_lease_id or ""], + ) + + async def drop(self, company_id: str, credit_type_id: str) -> None: + # A plain single-key delete: no secondary index to keep in sync. + await self._client.delete(self.hash_key(company_id, credit_type_id)) + + + def _is_noscript(err: Exception) -> bool: + """Did Redis answer that it no longer holds this script? + + Matched by exception name as well as message so the check does not depend + on importing redis, nor on a client's exact wording. + """ + return type(err).__name__ == "NoScriptError" or "NOSCRIPT" in str(err).upper() + + + def to_str(value: Any) -> str: + return value.decode("utf-8") if isinstance(value, bytes) else str(value) + + + def decode_hash(raw: Any) -> Dict[str, str]: + if not raw: + return {} + return {to_str(key): to_str(value) for key, value in raw.items()} + + + def _to_number(value: Any) -> float: + if value is None or value is False: + return 0.0 + if value is True: + return 1.0 + return float(to_str(value)) + + + def to_epoch_ms(epoch_seconds: float) -> str: + """Instants cross the wire as integer milliseconds, as the Node SDK writes them.""" + return str(int(round(epoch_seconds * 1000))) + + + def format_amount(value: float) -> str: + """Format a credit amount the way JavaScript would, so shared rows read alike.""" + if float(value).is_integer(): + return str(int(value)) + return repr(float(value)) + src/schematic/leases/redis_reservation_store.py: | + """Redis-backed reservation table: one hash per hold, two secondary indexes. + + Key layout, hash fields (camelCase), millisecond instants, the claim script, + and the TTL grace window match the Node SDK, so Node and Python pods sharing + one Redis read each other's holds. + + Every mutation is a single-key operation (or single-key Lua), so the store is + correct on standalone and clustered Redis alike: the unspent-slice refund is + delegated to the lease store rather than reaching across to the lease hash + inside a multi-key script. + """ + + from __future__ import annotations + + import json + import time + from typing import Any, Dict, List, Optional, Tuple + + from .lease_store import LeaseStore + from .redis_lease_store import DEFAULT_KEY_PREFIX, LuaScript, decode_hash, format_amount, to_epoch_ms, to_str + from .reservation_store import ReservationStore, clamp_consumption + from .types import Clock, ReservationRecord + + RES_KEY_NAMESPACE = "credit-reservation:" + # Sorted set scoring open reservations by expiry so the sweeper can pop expired + # entries in O(log n). Members encode the full (company, credit, id) tuple. + RES_INDEX_KEY = "credit-reservations:byExpiry" + # Per-(company, credit) index of open holds, one hash of id -> creditsReserved, + # so reserved_credits reads a tenant's holds with one HGETALL. The hash is also + # the source of truth for that sum: a field exists exactly while its + # reservation is open and unrefunded. + RES_BYCREDIT_NAMESPACE = "credit-reservations:byCredit:" + # Buffer past expiry before Redis evicts the row, so the sweeper has a window + # to refund. + RES_TTL_GRACE_MS = 30_000 + + # Page size for the sweeper's ZRANGEBYSCORE. Without a limit, a backlog of + # expired holds (after a Redis outage or a long pod pause) would come back as + # one giant reply on every pod's next tick; paging bounds the reply while the + # per-member ZREM keeps offset 0 advancing through the backlog. + SWEEP_BATCH_SIZE = 256 + # Upper bound on pages per tick: keeps one sweep's work bounded and guards + # against an endless loop if ZREM persistently fails. Anything left over is + # picked up next tick. + MAX_SWEEP_BATCHES = 16 + + # Atomic claim: read the reservation hash and delete it in one step, returning + # its fields (or nil if it was already gone). Touches a single key. The atomic + # read-then-delete is what makes consume exactly-once: of two racing callers (a + # normal settle and a sweeper, say) only one gets the fields back and proceeds + # to refund. The refund to the lease hash is a separate single-key op; a crash + # in the gap leaves the unspent slice held on the lease until the lease itself + # expires, never double-refunded. + CLAIM_SCRIPT = """ + local raw = redis.call('HGETALL', KEYS[1]) + if #raw == 0 then return nil end + redis.call('DEL', KEYS[1]) + return raw + """ + + + class RedisReservationStore(ReservationStore): + """Reservation table in Redis, refunding through a lease store it is given. + + ``client`` is a connected ``redis.asyncio.Redis``. + """ + + def __init__( + self, + client: Any, + lease_store: LeaseStore, + *, + key_prefix: str = DEFAULT_KEY_PREFIX, + clock: Clock = time.time, + ) -> None: + self._client = client + self._lease_store = lease_store + self._key_prefix = key_prefix + self._clock = clock + self._claim = LuaScript(CLAIM_SCRIPT) + + def _hash_key(self, reservation_id: str) -> str: + return f"{self._key_prefix}{RES_KEY_NAMESPACE}{reservation_id}" + + def _index_key(self) -> str: + return f"{self._key_prefix}{RES_INDEX_KEY}" + + def _by_credit_key(self, company_id: str, credit_type_id: str) -> str: + return f"{self._key_prefix}{RES_BYCREDIT_NAMESPACE}{company_id}:{credit_type_id}" + + async def add(self, reservation: ReservationRecord) -> None: + expires_ms = int(to_epoch_ms(reservation.expires_at)) + hash_key = self._hash_key(reservation.id) + # The hash goes out first so the reservation exists before anything + # references it. These are independent single-key ops rather than one + # multi-key script: a partial failure at worst leaves an un-indexed + # reservation that the TTL reaps, never a double-spend. + await self._client.hset( + hash_key, + mapping={ + "id": reservation.id, + "leaseId": reservation.lease_id, + "companyId": reservation.company_id, + "creditTypeId": reservation.credit_type_id, + "eventSubtype": reservation.event_subtype, + "quantityReserved": format_amount(reservation.quantity_reserved), + "creditsReserved": format_amount(reservation.credits_reserved), + "consumptionRate": format_amount(reservation.consumption_rate), + "expiresAt": str(expires_ms), + "evalCtx": _encode_eval_ctx(reservation), + }, + ) + await self._client.pexpireat(hash_key, expires_ms + RES_TTL_GRACE_MS) + member = _encode_member(reservation.company_id, reservation.credit_type_id, reservation.id) + await self._client.zadd(self._index_key(), {member: expires_ms}) + await self._client.hset( + self._by_credit_key(reservation.company_id, reservation.credit_type_id), + reservation.id, + format_amount(reservation.credits_reserved), + ) + + async def get(self, reservation_id: str) -> Optional[ReservationRecord]: + raw = decode_hash(await self._client.hgetall(self._hash_key(reservation_id))) + if not raw.get("id"): + return None + return _decode_reservation(raw) + + async def consume(self, reservation_id: str, credits_consumed: float) -> Optional[float]: + claimed = await self._claim.run(self._client, [self._hash_key(reservation_id)], []) + raw = _decode_flat(claimed) + if not raw or not raw.get("id"): + return None + + company_id = raw["companyId"] + credit_type_id = raw["creditTypeId"] + reserved = float(raw.get("creditsReserved", "0")) + + # Index cleanup, single-key ops. The per-tenant hash loses the slice + # BEFORE the refund below, so the lease (local remaining plus this + # hash) never transiently double-counts it. + member = _encode_member(company_id, credit_type_id, reservation_id) + await _ignore_errors(self._client.zrem(self._index_key(), member)) + await _ignore_errors(self._client.hdel(self._by_credit_key(company_id, credit_type_id), reservation_id)) + + consumed = clamp_consumption(credits_consumed, reserved) + refund = reserved - consumed + if refund > 0: + # The lease store owns the lease hash, which keeps this cross-key + # write out of a single Lua script. Pinned to the reservation's + # lease so a hold carved out of an expired lease cannot inflate a + # successor's balance. + await self._lease_store.refund(company_id, credit_type_id, refund, raw.get("leaseId")) + return consumed + + async def reserved_credits(self, company_id: str, credit_type_id: str) -> float: + raw = await _ignore_errors(self._client.hgetall(self._by_credit_key(company_id, credit_type_id))) + total = 0.0 + for value in decode_hash(raw).values(): + try: + total += float(value) + except ValueError: + continue + return total + + async def sweep_expired(self, now: Optional[float] = None) -> int: + cutoff = int(to_epoch_ms(self._clock() if now is None else now)) + swept = 0 + # Page through expired members rather than fetching them all at once. + # Each processed member is removed below, so re-reading at offset 0 + # advances through the backlog. + for _ in range(MAX_SWEEP_BATCHES): + expired = await self._client.zrangebyscore( + self._index_key(), 0, cutoff, start=0, num=SWEEP_BATCH_SIZE + ) + if not expired: + break + for member in expired: + member_str = to_str(member) + decoded = _decode_member(member_str) + if decoded is None: + # Nothing but `add` writes members, so this is + # belt-and-braces: drop it rather than let it wedge the + # sweeper. + await _ignore_errors(self._client.zrem(self._index_key(), member_str)) + continue + company_id, credit_type_id, reservation_id = decoded + refunded = await self.consume(reservation_id, 0) + # Always drop the member just read. On the success path + # `consume` already removed it, so this is idempotent; it also + # covers the hash-evicted path below. + await _ignore_errors(self._client.zrem(self._index_key(), member_str)) + if refunded is not None: + swept += 1 + continue + # No reservation hash: either a racing settle consumed it (and + # reconciled the byCredit field, making this a no-op) or the + # hash TTL-evicted before the sweeper reached it, orphaning the + # field. Reconcile so reserved_credits stops summing an evicted + # hold. Deliberately no refund: without the hash, exactly-once + # cannot be arbitrated across racing sweepers, so the slice + # waits for the lease to expire server-side. + await _ignore_errors( + self._client.hdel(self._by_credit_key(company_id, credit_type_id), reservation_id) + ) + if len(expired) < SWEEP_BATCH_SIZE: + break + return swept + + async def count(self) -> int: + result = await _ignore_errors(self._client.zcard(self._index_key())) + return int(result or 0) + + + def _encode_member(company_id: str, credit_type_id: str, reservation_id: str) -> str: + """Expiry-index members carry the whole tuple. + + The sweeper needs company and credit to clean the per-tenant hash even + after the reservation hash has TTL-evicted, at which point the claim + returns nil and cannot report them; otherwise the orphaned field would + inflate reserved_credits forever. The delimiter is absent from Schematic + ids and from the reservation id. + """ + return f"{company_id}|{credit_type_id}|{reservation_id}" + + + def _decode_member(member: str) -> Optional[Tuple[str, str, str]]: + parts = member.split("|") + if len(parts) != 3: + return None + return parts[0], parts[1], parts[2] + + + def _encode_eval_ctx(reservation: ReservationRecord) -> str: + ctx: Dict[str, Any] = {} + if reservation.company is not None: + ctx["company"] = reservation.company + if reservation.user is not None: + ctx["user"] = reservation.user + # Compact, like the Node SDK writes it, so a shared row reads identically. + return json.dumps(ctx, separators=(",", ":")) + + + def _decode_flat(raw: Any) -> Dict[str, str]: + """Decode the flat [field, value, ...] reply the claim script returns.""" + if not raw or not isinstance(raw, (list, tuple)): + return {} + items: List[str] = [to_str(item) for item in raw] + return {items[i]: items[i + 1] for i in range(0, len(items) - 1, 2)} + + + def _decode_reservation(raw: Dict[str, str]) -> ReservationRecord: + ctx: Dict[str, Any] = {} + if raw.get("evalCtx"): + try: + ctx = json.loads(raw["evalCtx"]) + except ValueError: + ctx = {} + return ReservationRecord( + id=raw["id"], + lease_id=raw.get("leaseId", ""), + company_id=raw.get("companyId", ""), + credit_type_id=raw.get("creditTypeId", ""), + event_subtype=raw.get("eventSubtype", ""), + quantity_reserved=float(raw.get("quantityReserved", "0")), + credits_reserved=float(raw.get("creditsReserved", "0")), + consumption_rate=float(raw.get("consumptionRate", "0")), + expires_at=float(raw.get("expiresAt", "0")) / 1000.0, + company=ctx.get("company"), + user=ctx.get("user"), + ) + + + async def _ignore_errors(awaitable: Any) -> Any: + """Index bookkeeping is best-effort: a failed cleanup must not abort a settle.""" + try: + return await awaitable + except Exception: + return None + src/schematic/leases/reservation_store.py: | + """Reservation table: the contract, plus the per-process in-memory backend. + + A reservation is a hold carved out of a lease. ``add`` does not debit: the + debit already landed in ``LeaseStore.try_reserve``, and that ordering is what + bounds a crash to a leaked hold rather than a double-spend. + """ + + from __future__ import annotations + + import abc + import math + import time + from typing import Dict, List, Optional + + from .lease_store import LeaseStore + from .types import Clock, ReservationRecord + + + class ReservationStore(abc.ABC): + """Backing store for open reservations, shared by both backends.""" + + @abc.abstractmethod + async def add(self, reservation: ReservationRecord) -> None: + """Register a reservation. Idempotent on id.""" + + @abc.abstractmethod + async def get(self, reservation_id: str) -> Optional[ReservationRecord]: + """Look up a reservation, or ``None`` once it has been claimed or swept.""" + + @abc.abstractmethod + async def consume(self, reservation_id: str, credits_consumed: float) -> Optional[float]: + """Claim a reservation exactly once and refund its unspent slice. + + The claim is atomic and comes first: a racing settle or sweep finds + nothing to claim, gets ``None``, and refunds nothing. On a successful + claim, ``credits_consumed`` is clamped to ``[0, credits_reserved]``, the + remainder is refunded to the lease (pinned to the reservation's lease), + and the clamped figure is returned. A crash between the claim and the + refund loses the refund, never double-refunds. + """ + + @abc.abstractmethod + async def reserved_credits(self, company_id: str, credit_type_id: str) -> float: + """Sum of ``credits_reserved`` across the slot's open reservations. + + A hold counts exactly while it is in the table, so + ``local_remaining_credits + reserved_credits`` stays exact between + operations. + """ + + @abc.abstractmethod + async def sweep_expired(self, now: Optional[float] = None) -> int: + """Remove every reservation past its TTL, refunding each full hold. + + Refunds are pinned to the originating lease, so a hold carved from a + lease that has since expired is dropped rather than credited to its + successor. Returns the number swept. + """ + + @abc.abstractmethod + async def count(self) -> int: + """Open reservations across every slot.""" + + + class InMemoryReservationStore(ReservationStore): + """Per-process reservation table refunding into a per-process lease store.""" + + def __init__(self, lease_store: LeaseStore, *, clock: Clock = time.time) -> None: + self._lease_store = lease_store + self._clock = clock + self._reservations: Dict[str, ReservationRecord] = {} + + async def add(self, reservation: ReservationRecord) -> None: + self._reservations[reservation.id] = reservation + + async def get(self, reservation_id: str) -> Optional[ReservationRecord]: + return self._reservations.get(reservation_id) + + async def consume(self, reservation_id: str, credits_consumed: float) -> Optional[float]: + # The claim: a dict pop with no await in it, so of two racing callers + # exactly one comes away with the record. + reservation = self._reservations.pop(reservation_id, None) + if reservation is None: + return None + consumed = clamp_consumption(credits_consumed, reservation.credits_reserved) + refund = reservation.credits_reserved - consumed + if refund > 0: + await self._lease_store.refund( + reservation.company_id, + reservation.credit_type_id, + refund, + reservation.lease_id, + ) + return consumed + + async def reserved_credits(self, company_id: str, credit_type_id: str) -> float: + return sum( + reservation.credits_reserved + for reservation in self._reservations.values() + if reservation.company_id == company_id and reservation.credit_type_id == credit_type_id + ) + + async def sweep_expired(self, now: Optional[float] = None) -> int: + cutoff = self._clock() if now is None else now + expired: List[str] = [ + reservation_id + for reservation_id, reservation in self._reservations.items() + if reservation.expires_at <= cutoff + ] + swept = 0 + for reservation_id in expired: + # Route through consume so the sweep claims exactly once too. + if await self.consume(reservation_id, 0) is not None: + swept += 1 + return swept + + async def count(self) -> int: + return len(self._reservations) + + + def clamp_consumption(credits_consumed: float, credits_reserved: float) -> float: + """Local bookkeeping never debits a lease past the hold it took.""" + if math.isnan(credits_consumed) or credits_consumed < 0: + return 0.0 + return min(credits_consumed, credits_reserved) + src/schematic/leases/track.py: | + """Settling a reservation: consume the hold, then build the billing event. + + The event is built from the caller-held handle rather than the store, so the + usage is still billed when the hold has already been swept. The server is the + source of truth for real consumption; the local bookkeeping only keeps the + lease's view honest. + """ + + from __future__ import annotations + + import math + from dataclasses import dataclass + from typing import TYPE_CHECKING, Optional + + from ..types import EventBodyTrack + from .reservation_store import ReservationStore + + if TYPE_CHECKING: + from ..client import Reservation, TrackWithReservationOptions + + + @dataclass + class ReservationConsumeResult: + """What a settle did locally, and what it owes the server.""" + + track: EventBodyTrack + # True when the hold was still open and this call debited the consumed + # slice and refunded the rest. False when it had already been swept at its + # TTL, already settled, or the store was unreachable: the lease balance was + # not touched here, so it reads high until the lease rolls over, and the + # track event is a recovery emit. + settled_locally: bool + + + async def consume_reservation_and_build_event( + reservations: ReservationStore, + reservation: "Reservation", + actual_quantity: float, + options: Optional["TrackWithReservationOptions"] = None, + ) -> ReservationConsumeResult: + """Settle a hold against its lease and build the track event that bills it.""" + credits = actual_quantity * reservation.consumption_rate + consumed = await reservations.consume(reservation.id, credits) + return ReservationConsumeResult( + track=build_reservation_track_event(reservation, settled_quantity(actual_quantity), options), + settled_locally=consumed is not None, + ) + + + def build_reservation_track_event( + reservation: "Reservation", + actual_quantity: int, + options: Optional["TrackWithReservationOptions"] = None, + ) -> EventBodyTrack: + """Build the track event that settles a reservation, from the handle alone. + + Kept free of store access so the client can still bill the usage when the + local settle fails against an unreachable store. + """ + return EventBodyTrack( + company=reservation.company, + event=reservation.event_subtype, + # A client-mode hold routes the server-side consumption through the + # lease's sub-ledger, instead of decrementing a grant the acquire + # already pre-debited. In server mode the hold lives on the server and + # settles by id; never send both, since the server prefers the lease id + # and there is no lease behind it. + lease_id=None if reservation.mode == "server" else reservation.lease_id, + quantity=actual_quantity, + reservation_id=reservation.id if reservation.mode == "server" else None, + traits=options.traits if options is not None else None, + user=reservation.user, + ) + + + def settled_quantity(actual_quantity: float) -> int: + """Cast a settled usage onto the integer a track event records. + + The hold can be sized from a fractional usage but the event's quantity is + an integer, so a partial unit settles as a whole one rather than as none. + """ + return int(actual_quantity) if float(actual_quantity).is_integer() else math.ceil(actual_quantity) + src/schematic/leases/types.py: | + """Shared types, defaults, and config resolution for client-mode credit leases. + + Durations are seconds (floats), like every other duration on this SDK, and + instants are epoch seconds (floats) rather than ``datetime`` objects: the + stores compare them against a clock the conformance runner can drive, and + Redis stores them as numbers anyway. The wire adapter converts at the + boundary. + """ + + from __future__ import annotations + + import math + from dataclasses import dataclass, field + from typing import Any, Callable, Dict, Mapping, Optional + + # Reads the current time as epoch seconds. Injected into every store and the + # lease manager so tests and the conformance runner can drive a virtual clock. + Clock = Callable[[], float] + + # Lease lifetime requested at acquire and extend (expires_at = now + duration). + DEFAULT_LEASE_DURATION = 300.0 + # Reservation lifetime, and the sweep deadline. Size it above the longest + # expected gap between check() and track_with_reservation(): a settle arriving + # after the TTL still bills the server but no longer re-debits the lease. + DEFAULT_RESERVATION_TTL = 60.0 + # Credits requested per acquire, and the minimum extend tranche. + DEFAULT_LEASE_SIZE = 10_000.0 + # Remaining/granted ratio at or below which a background extend is kicked off. + DEFAULT_LOW_WATER_MARK = 0.25 + # Expired-reservation sweep cadence. + DEFAULT_SWEEP_INTERVAL = 1.0 + # How long a prewarm waits for a freshly identified company to surface in the + # datastream cache before giving up. + DEFAULT_PREWARM_RESOLVE_TIMEOUT = 5.0 + + + @dataclass + class LeaseState: + """The local view of the one lease a ``(company, credit type)`` slot holds.""" + + lease_id: str + company_id: str + credit_type_id: str + # Server-authoritative total granted to this lease. Grows on extend. + granted_amount: float + # Granted minus outstanding holds and consumption. Starts at the full + # grant when the lease is installed. + local_remaining_credits: float + # Epoch seconds. Past it the lease is dead: the server has refunded the + # remainder to the company balance, so the local balance is stale. + expires_at: float + + + @dataclass + class ReservationRecord: + """One credit hold carved out of a lease by a check.""" + + id: str + # The lease the hold was carved from. Pins refunds so a hold from an + # expired lease can never inflate its successor. + lease_id: str + company_id: str + credit_type_id: str + # Event subtype the settling track event is billed as. + event_subtype: str + # Caller-declared usage, in event units. + quantity_reserved: float + # quantity_reserved * consumption_rate. + credits_reserved: float + consumption_rate: float + # Epoch seconds. The sweeper refunds the full hold past this instant. + expires_at: float + # Evaluation context the hold was issued for, threaded onto the track + # event so the server attributes usage to the same company and user. + company: Optional[Dict[str, str]] = None + user: Optional[Dict[str, str]] = None + + + @dataclass(frozen=True) + class ResolvedLeaseConfig: + """Config for a single credit type, after overrides and defaults.""" + + lease_duration: float = DEFAULT_LEASE_DURATION + reservation_ttl: float = DEFAULT_RESERVATION_TTL + lease_size: float = DEFAULT_LEASE_SIZE + low_water_mark: float = DEFAULT_LOW_WATER_MARK + + + @dataclass + class LeaseConfigOverride: + """Per-credit-type overrides of the four resolvable knobs.""" + + lease_duration: Optional[float] = None + reservation_ttl: Optional[float] = None + lease_size: Optional[float] = None + low_water_mark: Optional[float] = None + + + @dataclass + class LeaseConfig: + """Client-wide lease knobs, in seconds, plus per-credit-type overrides. + + The user-facing configuration dataclass lives on the client; this is the + plain-keyword form the lease machinery resolves against. + """ + + lease_duration: Optional[float] = None + reservation_ttl: Optional[float] = None + lease_size: Optional[float] = None + low_water_mark: Optional[float] = None + sweep_interval: Optional[float] = None + overrides: Mapping[str, LeaseConfigOverride] = field(default_factory=dict) + + + def resolve_lease_config( + config: Optional[LeaseConfig] = None, + overrides: Optional[Mapping[str, LeaseConfigOverride]] = None, + credit_type_id: Optional[str] = None, + ) -> ResolvedLeaseConfig: + """Resolve the knobs for one credit type: override, then client config, then default. + + ``overrides`` wins over ``config.overrides`` so a caller can resolve against + a one-off override map without rebuilding the config. + """ + override: Optional[LeaseConfigOverride] = None + if credit_type_id is not None: + table = overrides if overrides is not None else (config.overrides if config else None) + if table: + override = table.get(credit_type_id) + + def pick(name: str, default: float) -> float: + if override is not None: + value = getattr(override, name) + if value is not None: + return float(value) + if config is not None: + value = getattr(config, name) + if value is not None: + return float(value) + return default + + return ResolvedLeaseConfig( + lease_duration=pick("lease_duration", DEFAULT_LEASE_DURATION), + # Uncapped: a client-mode TTL only tells the local sweeper when to + # refund an unsettled hold, and never reaches the server. + reservation_ttl=pick("reservation_ttl", DEFAULT_RESERVATION_TTL), + lease_size=pick("lease_size", DEFAULT_LEASE_SIZE), + low_water_mark=pick("low_water_mark", DEFAULT_LOW_WATER_MARK), + ) + + + def is_valid_quantity(value: Any) -> bool: + """Whether a caller-supplied quantity can size a credit hold. + + A bool is an int in Python, and NaN and infinity are floats that slip + through every numeric comparison, so a hold would be sized from any of them + with nothing rejecting it. A NaN debit is the worst of the three: it + poisons a possibly shared lease balance into approving every later reserve. + """ + if isinstance(value, bool) or not isinstance(value, (int, float)): + return False + return math.isfinite(value) and value >= 0 + tests/conformance/test_vectors.py: | + """Runs the language-agnostic conformance vectors against this SDK. + + The vectors and the semantics they pin live in ``conformance/`` at the repo + root, copied verbatim from schematic-node (the reference implementation). This + runner is the only language-specific piece; every port reimplements it and must + pass the same vectors, on every store backend it ships. + + The flow-level ops (``check`` / ``track``) drive the real orchestration + against a scripted rules engine and a scripted DataStream, so what they pin is + what the SDK does around the engine, not the engine itself. + """ + + from __future__ import annotations + + import json + import logging + from pathlib import Path + from typing import Any, Callable, Dict, List, Optional + from unittest import mock + + import pytest + from lease_support import ( + CrashingRefundLeaseStore, + ScriptedDataStream, + ScriptedEngine, + ScriptedWireClient, + VirtualClock, + make_fake_redis, + ) + + from schematic.client import CheckOptions, Reservation + from schematic.leases import ( + CreditCheckDeps, + InMemoryLeaseStore, + InMemoryReservationStore, + LeaseConfig, + LeaseManager, + LeaseState, + LeaseStore, + RedisLeaseStore, + RedisReservationStore, + ReservationRecord, + ReservationStore, + check_with_lease, + consume_reservation_and_build_event, + ) + + VECTORS_DIR = Path(__file__).resolve().parents[2] / "conformance" / "vectors" + BACKENDS = ("in_memory", "redis") + # What the vectors write for the balance the fail-open evaluation substitutes: + # the largest integer a JSON number carries exactly. + MAX_SAFE_INTEGER = 2**53 - 1 + + + def _load_cases() -> List[Any]: + cases: List[Any] = [] + for path in sorted(VECTORS_DIR.glob("*.json")): + document = json.loads(path.read_text()) + for vector in document["vectors"]: + for backend in BACKENDS: + allowed = vector.get("backends") + if allowed and backend not in allowed: + continue + cases.append( + pytest.param( + backend, + vector, + id=f"{backend}-{document['category']}-{vector['name']}", + ) + ) + return cases + + + class Harness: + """One vector's stores, manager, clock, and reservation handles.""" + + def __init__(self, backend: str, config: Dict[str, Any]) -> None: + self.clock = VirtualClock() + self.handles: Dict[str, Reservation] = {} + self.wire = ScriptedWireClient() + self.leases: LeaseStore + self.reservations: ReservationStore + if backend == "in_memory": + self.leases = InMemoryLeaseStore(clock=self.clock) + self.crash = CrashingRefundLeaseStore(self.leases) + self.reservations = InMemoryReservationStore(self.crash, clock=self.clock) + else: + client = make_fake_redis() + self.leases = RedisLeaseStore(client, clock=self.clock) + self.crash = CrashingRefundLeaseStore(self.leases) + self.reservations = RedisReservationStore(client, self.crash, clock=self.clock) + self.manager = LeaseManager( + self.wire, + self.leases, + reservation_store=self.reservations, + config=LeaseConfig( + lease_duration=_seconds(config.get("lease_duration_ms")), + reservation_ttl=_seconds(config.get("reservation_ttl_ms")), + lease_size=config.get("lease_size"), + low_water_mark=config.get("low_water_mark"), + ), + clock=self.clock, + ) + + def at_ms(self, offset_ms: float) -> float: + return self.clock.at_ms(offset_ms) + + def reservation_id(self, op: Dict[str, Any]) -> str: + if "handle" in op: + reservation = self.handles.get(op["handle"]) + if reservation is None: + raise AssertionError(f"unknown reservation handle: {op['handle']}") + return reservation.id + if "id" not in op: + raise AssertionError(f"op {op['op']} needs an id or handle") + return str(op["id"]) + + async def drain(self) -> None: + """Let the manager's fire-and-forget work (a redundant release) finish.""" + await self.manager._drain_background() + + + @pytest.mark.parametrize("backend,vector", _load_cases()) + async def test_vector(backend: str, vector: Dict[str, Any]) -> None: + harness = Harness(backend, (vector.get("given") or {}).get("config") or {}) + # The Redis backend decides expiry against the store's own clock (TIME), + # so the virtual clock has to be the process clock too, not just the one + # the stores read. + with mock.patch("time.time", harness.clock): + for lease in (vector.get("given") or {}).get("leases") or []: + written = await harness.leases.replace( + lease_id=lease["lease_id"], + company_id=lease["company_id"], + credit_type_id=lease["credit_type_id"], + granted_amount=lease["granted_amount"], + expires_at=harness.at_ms(lease["expires_at_ms"]), + ) + assert written is True + for op in vector["operations"]: + handler = _HANDLERS.get(op["op"]) + if handler is None: + raise AssertionError(f"unknown conformance op: {op['op']}") + await handler(harness, op, op.get("expect") or {}) + harness.manager.stop() + + + async def _op_advance_clock(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + h.clock.advance_ms(op.get("ms") or 0) + + + async def _op_replace_lease(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + written = await h.leases.replace( + lease_id=op["lease_id"], + company_id=op["company_id"], + credit_type_id=op["credit_type_id"], + granted_amount=op["granted_amount"], + expires_at=h.at_ms(op["expires_at_ms"]), + ) + if "written" in expect: + assert written is expect["written"] + + + async def _op_drop_lease(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + await h.leases.drop(op["company_id"], op["credit_type_id"]) + + + async def _op_try_reserve(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + balance = await h.leases.try_reserve(op["company_id"], op["credit_type_id"], op["credits"]) + if "balance" in expect: + _assert_number(balance, expect["balance"]) + + + async def _op_refund_lease(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + await h.leases.refund(op["company_id"], op["credit_type_id"], op["credits"], op.get("pin_lease_id")) + + + async def _op_extend_lease(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + expires_at = h.at_ms(op["expires_at_ms"]) if "expires_at_ms" in op else None + await h.leases.extend( + op["company_id"], + op["credit_type_id"], + op["granted_total"], + expires_at, + op.get("pin_lease_id"), + ) + + + async def _op_get_lease(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + entry = await h.leases.get(op["company_id"], op["credit_type_id"]) + if "exists" in expect: + assert (entry is not None) is expect["exists"] + if "lease_id" in expect: + assert (entry.lease_id if entry else None) == expect["lease_id"] + if "granted_amount" in expect: + assert entry is not None and entry.granted_amount == expect["granted_amount"] + if "local_remaining_credits" in expect: + assert entry is not None and entry.local_remaining_credits == expect["local_remaining_credits"] + + + async def _op_add_reservation(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + await h.reservations.add( + ReservationRecord( + id=op["id"], + lease_id=op["lease_id"], + company_id=op["company_id"], + credit_type_id=op["credit_type_id"], + event_subtype=op["event_subtype"], + quantity_reserved=op["quantity_reserved"], + credits_reserved=op["credits_reserved"], + consumption_rate=op["consumption_rate"], + expires_at=h.at_ms(op["expires_at_ms"]), + company={"id": op["company_id"]}, + ) + ) + + + async def _op_consume_reservation(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + reservation_id = h.reservation_id(op) + if op.get("crash_before_refund"): + h.crash.arm() + with pytest.raises(RuntimeError, match="simulated crash before refund"): + await h.reservations.consume(reservation_id, op["credits"]) + assert expect.get("throws") is True + return + consumed = await h.reservations.consume(reservation_id, op["credits"]) + if "consumed" in expect: + _assert_number(consumed, expect["consumed"]) + + + async def _op_get_reservation(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + reservation = await h.reservations.get(h.reservation_id(op)) + if "exists" in expect: + assert (reservation is not None) is expect["exists"] + + + async def _op_reserved_credits(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + total = await h.reservations.reserved_credits(op["company_id"], op["credit_type_id"]) + assert total == expect["total"] + + + async def _op_reservation_count(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + assert await h.reservations.count() == expect["count"] + + + async def _op_sweep_expired(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + swept = await h.reservations.sweep_expired() + if "swept" in expect: + assert swept == expect["swept"] + + + async def _op_acquire_if_needed(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + if op.get("server"): + h.wire.acquire_responses.append(_server_script(h, op["server"])) + install = op.get("install_during_wire") + if install: + + async def install_lease() -> None: + await h.leases.replace( + lease_id=install["lease_id"], + company_id=install["company_id"], + credit_type_id=install["credit_type_id"], + granted_amount=install["granted_amount"], + expires_at=h.at_ms(install["expires_at_ms"]), + ) + + h.wire.during_acquire = install_lease + entry = await h.manager.acquire_if_needed(op["company_id"], op["credit_type_id"]) + await h.drain() + if "lease_id" in expect: + assert (entry.lease_id if entry else None) == expect["lease_id"] + if "wire_acquires" in expect: + assert len(h.wire.acquire_calls) == expect["wire_acquires"] + if "last_acquire_requested_amount" in expect: + assert h.wire.acquire_calls[-1]["requested_amount"] == expect["last_acquire_requested_amount"] + if "released_lease_ids" in expect: + assert h.wire.release_calls == expect["released_lease_ids"] + + + async def _op_maybe_extend(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + if op.get("server"): + h.wire.extend_responses.append(_server_script(h, op["server"])) + await h.manager.maybe_extend(op["company_id"], op["credit_type_id"], op.get("required_credits")) + await h.drain() + if "wire_extends" in expect: + assert len(h.wire.extend_calls) == expect["wire_extends"] + if "last_extend_additional_amount" in expect: + assert h.wire.extend_calls[-1]["additional_amount"] == expect["last_extend_additional_amount"] + if "last_extend_lease_id" in expect: + assert h.wire.extend_calls[-1]["lease_id"] == expect["last_extend_lease_id"] + + + async def _op_release_all_local_leases(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + await h.manager.release_all_local_leases() + if "released_lease_ids" in expect: + assert h.wire.release_calls == expect["released_lease_ids"] + if "remaining_slots" in expect: + remaining: Optional[List[LeaseState]] = h.leases.list_leases() + assert len(remaining or []) == expect["remaining_slots"] + + + async def _op_check(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + flag_key = op.get("flag_key") or "flag" + company = op.get("company") or {"id": "co_1"} + engine = ScriptedEngine(op.get("engine") or [], flag_key) + datastream = ScriptedDataStream(engine, flag_key, company) + for call, script in (op.get("server") or {}).items(): + queue = h.wire.acquire_responses if call == "acquire" else h.wire.extend_responses + queue.append(_server_script(h, script)) + + fallback_called = False + + async def fallback() -> Any: + nonlocal fallback_called + fallback_called = True + from schematic.client import CheckResult + + return CheckResult(allowed=True, value=True, reason="fallback", flag_key=flag_key) + + async def enqueue_flag_check(body: Any) -> None: + """The vectors pin the lease flow, not analytics, so drop the event.""" + + deps = CreditCheckDeps( + datastream=datastream, + lease_store=h.leases, + reservations=h.reservations, + manager=h.manager, + logger=logging.getLogger("conformance"), + enqueue_flag_check=enqueue_flag_check, + clock=h.clock, + ) + options = CheckOptions( + usage=op.get("usage"), + event_subtype=op.get("event_subtype"), + on_acquire_failure=op.get("on_acquire_failure") or "fail-closed", + ) + result = await check_with_lease(deps, flag_key, {"id": company["id"]}, None, options, fallback) + await h.drain() + + if "allowed" in expect: + assert result.allowed is expect["allowed"] + if "reason" in expect: + assert result.reason == expect["reason"] + if "err" in expect: + assert result.error == expect["err"] + if "has_reservation" in expect: + assert (result.reservation is not None) is expect["has_reservation"] + if "fallback_called" in expect: + assert fallback_called is expect["fallback_called"] + if expect.get("reservation"): + reservation = result.reservation + assert reservation is not None + for field, attribute in ( + ("lease_id", "lease_id"), + ("credit_type_id", "credit_type_id"), + ("event_subtype", "event_subtype"), + ("quantity_reserved", "quantity_reserved"), + ("credits_reserved", "credits_reserved"), + ("consumption_rate", "consumption_rate"), + ): + if field in expect["reservation"]: + assert getattr(reservation, attribute) == expect["reservation"][field] + if "engine_calls" in expect: + _assert_engine_calls(engine.calls, expect["engine_calls"], _credit_id(op)) + if "wire_extends" in expect: + assert len(h.wire.extend_calls) == expect["wire_extends"] + if "last_extend_additional_amount" in expect: + assert h.wire.extend_calls[-1]["additional_amount"] == expect["last_extend_additional_amount"] + if op.get("save_reservation_as") and result.reservation is not None: + h.handles[op["save_reservation_as"]] = result.reservation + + + async def _op_track(h: Harness, op: Dict[str, Any], expect: Dict[str, Any]) -> None: + reservation = h.handles.get(op["handle"]) + if reservation is None: + raise AssertionError(f"unknown reservation handle: {op['handle']}") + outcome = await consume_reservation_and_build_event(h.reservations, reservation, op["actual_quantity"]) + if "settled_locally" in expect: + assert outcome.settled_locally is expect["settled_locally"] + track = expect.get("track") or {} + if "event" in track: + assert outcome.track.event == track["event"] + if "quantity" in track: + assert outcome.track.quantity == track["quantity"] + if "lease_id" in track: + assert outcome.track.lease_id == track["lease_id"] + + + def _credit_id(op: Dict[str, Any]) -> Optional[str]: + """The credit the vector's engine_calls expectations are keyed on.""" + for scripted in op.get("engine") or []: + credit_id = (scripted.get("entitlement") or {}).get("credit_id") + if credit_id: + return str(credit_id) + balances = (op.get("company") or {}).get("credit_balances") or {} + return next(iter(balances), None) + + + def _assert_engine_calls( + recorded: List[Dict[str, Any]], expected: List[Dict[str, Any]], credit_id: Optional[str], + ) -> None: + assert len(recorded) == len(expected) + for got, want in zip(recorded, expected): + if "credit_balance" in want: + balance = want["credit_balance"] + assert credit_id is not None + assert got["credit_balances"].get(credit_id) == ( + MAX_SAFE_INTEGER if balance == "max_safe_integer" else balance + ) + if "credit_cost" in want: + assert (got["credit_cost"] or {}).get(credit_id) == want["credit_cost"] + if "event_usage" in want: + assert got["event_usage"] == want["event_usage"] + if "usage" in want: + assert got["usage"] == want["usage"] + + + _Handler = Callable[[Harness, Dict[str, Any], Dict[str, Any]], Any] + + _HANDLERS: Dict[str, _Handler] = { + "advance_clock": _op_advance_clock, + "replace_lease": _op_replace_lease, + "drop_lease": _op_drop_lease, + "try_reserve": _op_try_reserve, + "refund_lease": _op_refund_lease, + "extend_lease": _op_extend_lease, + "get_lease": _op_get_lease, + "add_reservation": _op_add_reservation, + "consume_reservation": _op_consume_reservation, + "get_reservation": _op_get_reservation, + "reserved_credits": _op_reserved_credits, + "reservation_count": _op_reservation_count, + "sweep_expired": _op_sweep_expired, + "acquire_if_needed": _op_acquire_if_needed, + "maybe_extend": _op_maybe_extend, + "release_all_local_leases": _op_release_all_local_leases, + "check": _op_check, + "track": _op_track, + } + + + def _server_script(h: Harness, server: Dict[str, Any]) -> Dict[str, Any]: + """Turn a vector's server script into one the wire stand-in can serve.""" + if server.get("error") is not None: + return {"error": server["error"]} + lease: Dict[str, Any] = dict(server["lease"]) + lease["expires_at"] = h.at_ms(lease["expires_at_ms"]) + return {"lease": lease} + + + def _assert_number(actual: Optional[float], expected: Optional[float]) -> None: + """``None`` is the refused result, so it never compares equal to a figure.""" + if expected is None: + assert actual is None + return + assert actual is not None and actual == expected + + + def _seconds(milliseconds: Optional[float]) -> Optional[float]: + return None if milliseconds is None else milliseconds / 1000.0 + tests/lease_support.py: | + """Harness shared by the lease unit tests and the conformance runner. + + Everything here is test-only: the virtual clock, the fakeredis client the + verbatim Lua scripts can run against, the crash seam the bounded-leak tests + need, and scriptable stand-ins for the lease wire API, the rules engine, and + DataStream. + """ + + from __future__ import annotations + + import datetime as dt + from typing import Any, Awaitable, Dict, List, Optional, cast + + import fakeredis.aioredis + + from schematic.leases import LeaseGrant, LeaseState, ReservationRecord + from schematic.leases.lease_store import LeaseStore + from schematic.types import ( + RulesengineCheckFlagResult, + RulesengineCompany, + RulesengineFeatureEntitlement, + RulesengineFlag, + ) + + # The fixed virtual instant every vector and test starts from. + T0 = dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc).timestamp() + + + class VirtualClock: + """A clock only ``advance`` moves, so no test depends on wall time.""" + + def __init__(self, now: float = T0) -> None: + self._now = now + + def __call__(self) -> float: + return self._now + + def advance_ms(self, milliseconds: float) -> None: + self._now += milliseconds / 1000.0 + + def at_ms(self, offset_ms: float) -> float: + """An absolute position on the virtual timeline, as the vectors express it.""" + return T0 + offset_ms / 1000.0 + + + class LuaCompatFakeRedis(fakeredis.aioredis.FakeRedis): + """fakeredis with the one Lua builtin its runtime lacks papered over. + + ``redis.replicate_commands()`` is what lets a real Redis 5/6 read TIME in a + writing script; fakeredis's Lua runtime does not define it. Stripping the + call here (rather than dropping it from the scripts) keeps the shipped Lua + byte-identical to the Node SDK's, which is what lets both fleets share one + Redis. The scripts are sent through SCRIPT LOAD as well as EVAL, so both + are rewritten and the server-assigned digest stays consistent. + """ + + @staticmethod + def _strip(script: str) -> str: + return script.replace("redis.replicate_commands()\n", "") + + async def script_load(self, script: str) -> Any: # type: ignore[override] + return await cast(Awaitable[Any], super().script_load(self._strip(script))) + + async def eval(self, script: str, numkeys: int, *keys_and_args: Any) -> Any: # type: ignore[override] + return await cast(Awaitable[Any], super().eval(self._strip(script), numkeys, *keys_and_args)) + + + def make_fake_redis() -> LuaCompatFakeRedis: + return LuaCompatFakeRedis(decode_responses=True) + + + class CrashingRefundLeaseStore(LeaseStore): + """Lease store whose ``refund`` raises once while armed. + + The reservation store refunds through the store it is handed, so wrapping + that one reproduces a process death between the claim and the refund while + the test body keeps reading the real store. + """ + + def __init__(self, target: LeaseStore) -> None: + self._target = target + self._armed = False + + def arm(self) -> None: + self._armed = True + + async def get(self, company_id: str, credit_type_id: str) -> Optional[LeaseState]: + return await self._target.get(company_id, credit_type_id) + + async def replace( + self, + *, + lease_id: str, + company_id: str, + credit_type_id: str, + granted_amount: float, + expires_at: float, + ) -> bool: + return await self._target.replace( + lease_id=lease_id, + company_id=company_id, + credit_type_id=credit_type_id, + granted_amount=granted_amount, + expires_at=expires_at, + ) + + async def try_reserve(self, company_id: str, credit_type_id: str, credits: float) -> Optional[float]: + return await self._target.try_reserve(company_id, credit_type_id, credits) + + async def refund( + self, + company_id: str, + credit_type_id: str, + credits: float, + pin_lease_id: Optional[str] = None, + ) -> None: + if self._armed: + self._armed = False + raise RuntimeError("simulated crash before refund") + await self._target.refund(company_id, credit_type_id, credits, pin_lease_id) + + async def extend( + self, + company_id: str, + credit_type_id: str, + granted_total: float, + new_expires_at: Optional[float] = None, + pin_lease_id: Optional[str] = None, + ) -> None: + await self._target.extend(company_id, credit_type_id, granted_total, new_expires_at, pin_lease_id) + + async def drop(self, company_id: str, credit_type_id: str) -> None: + await self._target.drop(company_id, credit_type_id) + + def list_leases(self) -> Optional[List[LeaseState]]: + return self._target.list_leases() + + + class ScriptedWireClient: + """Stands in for the lease API: queued responses in, recorded calls out.""" + + def __init__(self) -> None: + self.acquire_responses: List[Dict[str, Any]] = [] + self.extend_responses: List[Dict[str, Any]] = [] + self.acquire_calls: List[Dict[str, Any]] = [] + self.extend_calls: List[Dict[str, Any]] = [] + self.release_calls: List[str] = [] + # Runs while an acquire is in flight, for emulating a sibling pod + # winning the race. + self.during_acquire: Optional[Any] = None + + async def acquire( + self, + company_id: str, + credit_type_id: str, + requested_amount: float, + expires_at: float, + timeout: Optional[float] = None, + ) -> LeaseGrant: + self.acquire_calls.append( + { + "company_id": company_id, + "credit_type_id": credit_type_id, + "requested_amount": requested_amount, + "expires_at": expires_at, + "timeout": timeout, + } + ) + during = self.during_acquire + if during is not None: + self.during_acquire = None + await during() + scripted = self.acquire_responses.pop(0) if self.acquire_responses else None + lease = _scripted_lease(scripted, "unscripted acquire wire call") + return LeaseGrant( + lease_id=lease.get("lease_id", "lse_unnamed"), + company_id=company_id, + credit_type_id=credit_type_id, + granted_amount=float(lease.get("granted_amount", 0)), + expires_at=lease["expires_at"], + ) + + async def extend( + self, + lease_id: str, + additional_amount: float, + expires_at: float, + timeout: Optional[float] = None, + ) -> LeaseGrant: + self.extend_calls.append( + { + "lease_id": lease_id, + "additional_amount": additional_amount, + "expires_at": expires_at, + "timeout": timeout, + } + ) + scripted = self.extend_responses.pop(0) if self.extend_responses else None + lease = _scripted_lease(scripted, "unscripted extend wire call") + return LeaseGrant( + lease_id=lease_id, + company_id=lease.get("company_id", "co_wire"), + credit_type_id=lease.get("credit_type_id", "ct_wire"), + granted_amount=float(lease.get("granted_total", lease.get("granted_amount", 0))), + expires_at=lease["expires_at"], + ) + + async def release(self, lease_id: str) -> None: + self.release_calls.append(lease_id) + + + def _scripted_lease(scripted: Optional[Dict[str, Any]], missing: str) -> Dict[str, Any]: + if scripted is None: + raise RuntimeError(missing) + if scripted.get("error") is not None or not scripted.get("lease"): + raise RuntimeError(str(scripted.get("error") or missing)) + lease: Dict[str, Any] = dict(scripted["lease"]) + return lease + + + def make_reservation(**overrides: Any) -> ReservationRecord: + fields: Dict[str, Any] = { + "id": "res_1", + "lease_id": "lse_1", + "company_id": "co_1", + "credit_type_id": "ct_1", + "event_subtype": "inference_tokens", + "quantity_reserved": 10, + "credits_reserved": 100, + "consumption_rate": 10, + "expires_at": T0 + 60, + "company": {"id": "co_1"}, + } + fields.update(overrides) + return ReservationRecord(**fields) + + + class ScriptedEngine: + """Stands in for the WASM rules engine: queued verdicts in, calls out. + + The vectors treat the engine as an oracle. What they pin is the + orchestration around it, so every call records the credit balance the SDK + substituted and the preflight it threaded. + """ + + def __init__(self, results: List[Dict[str, Any]], flag_key: str = "flag") -> None: + self._results = list(results) + self._flag_key = flag_key + self.calls: List[Dict[str, Any]] = [] + + def __call__( + self, + flag: Any, + company: Any, + user: Any, + options: Any = None, + ) -> RulesengineCheckFlagResult: + event_usage = getattr(options, "event_usage", None) + self.calls.append( + { + "credit_balances": dict(getattr(company, "credit_balances", None) or {}), + "credit_cost": getattr(options, "credit_cost", None), + "event_usage": ( + {"event_subtype": event_usage.event_subtype, "quantity": event_usage.quantity} + if event_usage is not None + else None + ), + "usage": getattr(options, "usage", None), + } + ) + if not self._results: + raise RuntimeError(f"unscripted engine call for flag {self._flag_key}") + scripted = self._results.pop(0) + entitlement = scripted.get("entitlement") + return RulesengineCheckFlagResult( + value=scripted["value"], + reason=scripted.get("reason") or "", + flag_key=self._flag_key, + flag_id="flag_1", + entitlement=_scripted_entitlement(entitlement, self._flag_key) if entitlement else None, + ) + + + class ScriptedDataStream: + """The slice of ``DataStreamClient`` a lease-bearing check touches. + + The keyword arguments stage the misses the check flow has to survive: a + flag that is not cached, a company or user the socket cannot resolve. + """ + + def __init__( + self, + engine: Any, + flag_key: str, + company: Dict[str, Any], + *, + user: Optional[Any] = None, + missing_flag: bool = False, + company_error: Optional[Exception] = None, + user_error: Optional[Exception] = None, + company_cached: bool = False, + ) -> None: + self._engine = engine + self._flag_key = flag_key + self._company = make_company(company["id"], company.get("credit_balances") or {}) + self._user = user + self._missing_flag = missing_flag + self._company_error = company_error + self._user_error = user_error + self._company_cached = company_cached + + async def get_flag(self, flag_key: str) -> Optional[RulesengineFlag]: + if self._missing_flag: + return None + return RulesengineFlag( + id="flag_1", + key=self._flag_key, + account_id="acc_1", + environment_id="env_1", + default_value=False, + rules=[], + ) + + async def get_company(self, keys: Dict[str, str]) -> RulesengineCompany: + if self._company_error is not None: + raise self._company_error + return self._company + + async def get_cached_company(self, keys: Dict[str, str]) -> Optional[RulesengineCompany]: + return self._company if self._company_cached else None + + async def get_user(self, keys: Dict[str, str]) -> Any: + if self._user_error is not None: + raise self._user_error + return self._user + + def evaluate_flag(self, flag: Any, company: Any, user: Any, options: Any = None) -> RulesengineCheckFlagResult: + return self._engine(flag, company, user, options) + + + def make_company(company_id: str, credit_balances: Dict[str, float]) -> RulesengineCompany: + return RulesengineCompany( + id=company_id, + account_id="acc_1", + environment_id="env_1", + keys={"id": company_id}, + traits=[], + metrics=[], + rules=[], + entitlements=[], + billing_product_ids=[], + credit_balances=dict(credit_balances), + plan_ids=[], + plan_version_ids=[], + ) + + + def _scripted_entitlement(spec: Dict[str, Any], flag_key: str) -> RulesengineFeatureEntitlement: + return RulesengineFeatureEntitlement( + feature_id=spec.get("feature_id") or "feat_1", + feature_key=spec.get("feature_key") or flag_key, + value_type=spec["value_type"], + credit_id=spec.get("credit_id"), + consumption_rate=spec.get("consumption_rate"), + event_subtype=spec.get("event_subtype"), + ) + tests/leases/__init__.py: "" + tests/leases/conftest.py: | + from __future__ import annotations + + from typing import Iterator + from unittest import mock + + import pytest + from lease_support import VirtualClock, make_fake_redis + + + @pytest.fixture + def clock() -> VirtualClock: + return VirtualClock() + + + @pytest.fixture + def frozen_clock(clock: VirtualClock) -> Iterator[VirtualClock]: + """A virtual clock that is also the process clock. + + The Redis stores decide expiry against the store's own clock (Redis TIME), + which fakeredis reads from ``time.time``, so a test that moves the virtual + clock has to move that one too. + """ + with mock.patch("time.time", clock): + yield clock + + + @pytest.fixture + def redis_client(frozen_clock: VirtualClock) -> object: + return make_fake_redis() + tests/leases/test_check_and_track.py: | + """The client-mode check and settle flow against scripted stores and engine. + + Ports schematic-node's check-and-track suite. The engine is scripted rather + than real (``test_wasm_credit_gate`` drives the real one), so what these pin is + the orchestration: which balance the engine is handed, when a hold is taken, + and what happens to it when something downstream says no. + """ + + from __future__ import annotations + + import asyncio + import logging + from dataclasses import dataclass, field + from typing import Any, Dict, List, Optional + + import pytest + from lease_support import ScriptedDataStream, ScriptedWireClient, VirtualClock + + from schematic.client import CheckOptions, CheckResult, Reservation + from schematic.leases import ( + CreditCheckDeps, + InMemoryLeaseStore, + InMemoryReservationStore, + LeaseConfig, + LeaseManager, + LeaseStore, + ReservationRecord, + check_with_lease, + consume_reservation_and_build_event, + ) + from schematic.leases.check import FAIL_OPEN_BALANCE + from schematic.types import ( + EventBodyFlagCheck, + RulesengineCheckFlagResult, + RulesengineFeatureEntitlement, + RulesengineUser, + ) + + FLAG_KEY = "inference" + CREDIT_ID = "bilcr_inference" + EVENT_SUBTYPE = "inference_tokens" + LEASE_SIZE = 1000.0 + LEASE_DURATION = 300.0 + COMPANY = {"id": "co_1"} + + CREDIT_ENTITLEMENT = RulesengineFeatureEntitlement( + feature_id="feat", + feature_key=FLAG_KEY, + value_type="credit", + credit_id=CREDIT_ID, + consumption_rate=10, + event_subtype=EVENT_SUBTYPE, + ) + + + def _verdict( + value: bool, reason: str, entitlement: Optional[RulesengineFeatureEntitlement] = None, + ) -> RulesengineCheckFlagResult: + return RulesengineCheckFlagResult( + value=value, reason=reason, flag_key=FLAG_KEY, flag_id="flag_1", entitlement=entitlement + ) + + + class FlowEngine: + """The engine the lease path asks twice: the probe, then the gate. + + The gate is the call carrying ``credit_cost``, which is also what puts the + fail-open re-evaluation on the probe branch, as it is in the reference + implementation. + """ + + def __init__( + self, + *, + probe: Optional[RulesengineCheckFlagResult] = None, + gate: Optional[RulesengineCheckFlagResult] = None, + probe_error: Optional[Exception] = None, + gate_error: Optional[Exception] = None, + ) -> None: + self.probe = probe if probe is not None else _verdict(True, "probe", CREDIT_ENTITLEMENT) + self.gate = gate if gate is not None else _verdict(True, "matched", CREDIT_ENTITLEMENT) + self.probe_error = probe_error + self.gate_error = gate_error + self.calls: List[Dict[str, Any]] = [] + + def __call__(self, flag: Any, company: Any, user: Any, options: Any = None) -> RulesengineCheckFlagResult: + gating = bool(options is not None and getattr(options, "credit_cost", None)) + self.calls.append({"company": company, "user": user, "options": options, "gating": gating}) + if gating: + if self.gate_error is not None: + raise self.gate_error + return self.gate + if self.probe_error is not None: + raise self.probe_error + return self.probe + + @property + def gate_call(self) -> Optional[Dict[str, Any]]: + return next((call for call in self.calls if call["gating"]), None) + + def balance(self, index: int) -> float: + return float(self.calls[index]["company"].credit_balances[CREDIT_ID]) + + + class _ProbeThenExplodes(FlowEngine): + """Answers the probe, then fails every evaluation after it.""" + + def __call__(self, flag: Any, company: Any, user: Any, options: Any = None) -> RulesengineCheckFlagResult: + result = super().__call__(flag, company, user, options) + if len(self.calls) > 1: + raise RuntimeError("wasm exploded") + return result + + + class Fallback: + """The plain flag check the lease path defers to.""" + + def __init__(self) -> None: + self.called = False + + async def __call__(self) -> CheckResult: + self.called = True + return CheckResult(allowed=True, value=True, reason="fallback", flag_key=FLAG_KEY) + + + class UnreachableLeaseStore(InMemoryLeaseStore): + """A store that can be read but never debited, as an unreachable Redis is.""" + + async def try_reserve(self, company_id: str, credit_type_id: str, credits: float) -> Optional[float]: + raise RuntimeError("redis down") + + + class RecordedFlagChecks: + """Stands in for the client's event buffer so the tests can see what the + lease path reported.""" + + def __init__(self) -> None: + self.events: List[EventBodyFlagCheck] = [] + self.explode = False + + async def __call__(self, body: EventBodyFlagCheck) -> None: + if self.explode: + raise RuntimeError("event buffer down") + self.events.append(body) + + + @dataclass + class Flow: + deps: CreditCheckDeps + engine: FlowEngine + wire: ScriptedWireClient + leases: LeaseStore + reservations: InMemoryReservationStore + manager: LeaseManager + flag_checks: RecordedFlagChecks = field(default_factory=RecordedFlagChecks) + fallback: Fallback = field(default_factory=Fallback) + + async def check(self, **option_overrides: Any) -> CheckResult: + options = CheckOptions(usage=50, event_subtype=EVENT_SUBTYPE) + for name, value in option_overrides.items(): + setattr(options, name, value) + result = await check_with_lease(self.deps, FLAG_KEY, COMPANY, None, options, self.fallback) + await self.manager._drain_background() + return result + + async def remaining(self) -> Optional[float]: + entry = await self.leases.get(COMPANY["id"], CREDIT_ID) + return entry.local_remaining_credits if entry is not None else None + + + def make_flow( + clock: VirtualClock, + *, + engine: Optional[FlowEngine] = None, + lease_store: Optional[LeaseStore] = None, + acquire: str = "ok", + credit_balances: Optional[Dict[str, float]] = None, + **datastream_kwargs: Any, + ) -> Flow: + engine = engine or FlowEngine() + leases = lease_store if lease_store is not None else InMemoryLeaseStore(clock=clock) + reservations = InMemoryReservationStore(leases, clock=clock) + wire = ScriptedWireClient() + if acquire == "ok": + wire.acquire_responses.append( + {"lease": {"lease_id": "lse_1", "granted_amount": LEASE_SIZE, "expires_at": clock() + LEASE_DURATION}} + ) + elif acquire == "error": + wire.acquire_responses.append({"error": "lease 503"}) + manager = LeaseManager( + wire, + leases, + reservation_store=reservations, + config=LeaseConfig( + lease_duration=LEASE_DURATION, reservation_ttl=60.0, lease_size=LEASE_SIZE, low_water_mark=0.25 + ), + clock=clock, + ) + datastream = ScriptedDataStream( + engine, + FLAG_KEY, + {"id": COMPANY["id"], "credit_balances": credit_balances if credit_balances is not None else {CREDIT_ID: 5000}}, + **datastream_kwargs, + ) + flag_checks = RecordedFlagChecks() + return Flow( + deps=CreditCheckDeps( + datastream=datastream, + lease_store=leases, + reservations=reservations, + manager=manager, + logger=logging.getLogger("lease-flow-test"), + enqueue_flag_check=flag_checks, + clock=clock, + ), + engine=engine, + wire=wire, + leases=leases, + reservations=reservations, + manager=manager, + flag_checks=flag_checks, + ) + + + class TestCheckWithLease: + async def test_issues_a_reservation_when_the_engine_allows(self, clock: VirtualClock) -> None: + flow = make_flow(clock) + result = await flow.check() + + assert result.allowed is True + assert result.reservation is not None + assert result.reservation.mode == "client" + assert result.reservation.lease_id == "lse_1" + assert result.reservation.quantity_reserved == 50 + assert result.reservation.credits_reserved == 500 + assert result.reservation.consumption_rate == 10 + assert len(flow.wire.acquire_calls) == 1 + # The probe sees the company's real balance; the gate sees the + # pre-reservation lease balance and the cost this call just debited. + assert flow.engine.balance(0) == 5000 + assert flow.engine.balance(1) == LEASE_SIZE + assert flow.engine.gate_call is not None + assert flow.engine.gate_call["options"].credit_cost == {CREDIT_ID: 500} + assert await flow.remaining() == 500 + assert await flow.reservations.count() == 1 + + async def test_leases_the_credit_the_matched_entitlement_names(self, clock: VirtualClock) -> None: + # Entitlement-first resolution: the credit and the rate come off the + # probe's entitlement, whatever the flag's conditions look like. + ai_entitlement = RulesengineFeatureEntitlement( + feature_id="feat", + feature_key=FLAG_KEY, + value_type="credit", + credit_id="bilcr_ai", + consumption_rate=5, + event_subtype=EVENT_SUBTYPE, + ) + engine = FlowEngine( + probe=_verdict(True, "probe", ai_entitlement), gate=_verdict(True, "matched", ai_entitlement) + ) + flow = make_flow(clock, engine=engine) + flow.wire.acquire_responses[0]["lease"]["lease_id"] = "lse_ai" + result = await flow.check() + + assert result.reservation is not None + assert result.reservation.credit_type_id == "bilcr_ai" + assert result.reservation.consumption_rate == 5 + assert result.reservation.credits_reserved == 250 + assert flow.wire.acquire_calls[0]["credit_type_id"] == "bilcr_ai" + assert len(flow.engine.calls) == 2 + + async def test_skips_the_lease_when_the_entitlement_is_not_credit_metered(self, clock: VirtualClock) -> None: + # A boolean grant (an override, say) draws no credit, so the lease path + # never acquires: the plain check decides, with no reserve to cancel. + entitlement = RulesengineFeatureEntitlement(feature_id="feat", feature_key=FLAG_KEY, value_type="boolean") + flow = make_flow(clock, engine=FlowEngine(probe=_verdict(True, "override", entitlement))) + result = await flow.check() + + assert flow.fallback.called is True + assert result.allowed is True + assert result.reservation is None + assert flow.wire.acquire_calls == [] + assert flow.engine.gate_call is None + + async def test_denies_and_refunds_when_the_gate_denies(self, clock: VirtualClock) -> None: + flow = make_flow(clock, engine=FlowEngine(gate=_verdict(False, "denied_by_targeting"))) + result = await flow.check() + + assert result.allowed is False + assert result.reason == "denied_by_targeting" + assert result.reservation is None + assert await flow.remaining() == LEASE_SIZE + assert await flow.reservations.count() == 0 + + async def test_fails_closed_when_the_acquire_fails(self, clock: VirtualClock) -> None: + flow = make_flow(clock, acquire="error") + result = await flow.check(on_acquire_failure="fail-closed") + + assert result.allowed is False + assert result.reason == "lease_acquire_failed" + assert result.error == "lease_acquire_failed" + assert result.reservation is None + assert flow.engine.gate_call is None + + async def test_defaults_to_fail_closed(self, clock: VirtualClock) -> None: + flow = make_flow(clock, acquire="error") + result = await flow.check() + + assert result.allowed is False + assert result.reservation is None + + async def test_fail_open_re_evaluates_with_the_balance_assumed_sufficient(self, clock: VirtualClock) -> None: + flow = make_flow(clock, acquire="error") + result = await flow.check(on_acquire_failure="fail-open") + + assert result.allowed is True + assert result.error == "lease_acquire_failed" + assert result.reservation is None + # Fail-open runs the rules, it does not skip them: only the credit + # balance is assumed sufficient, and the caller's usage still rides in. + assert flow.engine.balance(1) == FAIL_OPEN_BALANCE + preflight = flow.engine.calls[1]["options"].event_usage + assert (preflight.event_subtype, preflight.quantity) == (EVENT_SUBTYPE, 50) + + async def test_fail_open_still_denies_a_company_the_rules_do_not_entitle(self, clock: VirtualClock) -> None: + flow = make_flow( + clock, + engine=FlowEngine(probe=_verdict(False, "no matching rule", CREDIT_ENTITLEMENT)), + acquire="error", + ) + result = await flow.check(on_acquire_failure="fail-open") + + assert result.allowed is False + assert result.reason == "no matching rule (lease_acquire_failed_fail_open)" + assert result.error == "lease_acquire_failed" + + async def test_fail_open_allows_outright_when_the_re_evaluation_errors(self, clock: VirtualClock) -> None: + # The probe resolves the credit, the acquire fails, and then the + # fail-open re-evaluation throws, leaving only the blanket allow. + flow = make_flow(clock, engine=_ProbeThenExplodes(), acquire="error") + result = await flow.check(on_acquire_failure="fail-open") + + assert result.allowed is True + assert result.reason == "lease_acquire_failed_fail_open" + assert result.reservation is None + + async def test_falls_back_when_the_probe_errors(self, clock: VirtualClock) -> None: + # A probe failure is a resolution miss, not the gate: the plain check + # has its own degradation, and no lease is acquired. + flow = make_flow(clock, engine=FlowEngine(probe_error=RuntimeError("wasm exploded"))) + result = await flow.check() + + assert flow.fallback.called is True + assert result.allowed is True + assert flow.wire.acquire_calls == [] + + async def test_falls_back_when_the_flag_is_not_cached(self, clock: VirtualClock) -> None: + flow = make_flow(clock, missing_flag=True) + result = await flow.check() + + assert flow.fallback.called is True + assert result.reservation is None + assert flow.engine.calls == [] + + async def test_zero_usage_falls_back_without_a_hold(self, clock: VirtualClock) -> None: + flow = make_flow(clock) + result = await flow.check(usage=0) + + assert flow.fallback.called is True + assert result.reservation is None + assert flow.wire.acquire_calls == [] + assert flow.engine.calls == [] + + async def test_falls_back_when_nothing_names_the_event_subtype(self, clock: VirtualClock) -> None: + # The hold settles into a track event named by the subtype; without one + # it could be consumed while billing nothing. + entitlement = RulesengineFeatureEntitlement( + feature_id="feat", + feature_key=FLAG_KEY, + value_type="credit", + credit_id=CREDIT_ID, + consumption_rate=10, + ) + flow = make_flow(clock, engine=FlowEngine(probe=_verdict(True, "probe", entitlement))) + result = await flow.check(event_subtype=None) + + assert flow.fallback.called is True + assert result.reservation is None + assert flow.wire.acquire_calls == [] + + async def test_rejects_a_nan_usage_without_touching_the_lease(self, clock: VirtualClock) -> None: + # An unguarded NaN debit poisons the shared lease balance into + # approving every later reserve, since NaN loses every comparison. + flow = make_flow(clock) + denied = await flow.check(usage=float("nan")) + + assert denied.allowed is False + assert denied.error == "invalid_usage" + assert denied.reservation is None + assert flow.wire.acquire_calls == [] + assert flow.engine.calls == [] + + allowed = await flow.check() + assert allowed.allowed is True + assert allowed.reservation is not None + assert allowed.reservation.credits_reserved == 500 + + async def test_resolves_an_invalid_usage_through_fail_open(self, clock: VirtualClock) -> None: + flow = make_flow(clock) + result = await flow.check(usage=-10, on_acquire_failure="fail-open") + + assert result.allowed is True + assert result.error == "invalid_usage" + assert result.reservation is None + assert flow.wire.acquire_calls == [] + + async def test_extends_once_and_retries_when_the_lease_is_short(self, clock: VirtualClock) -> None: + flow = make_flow(clock) + await flow.check() # draws the lease down to 500 + flow.wire.extend_responses.append( + {"lease": {"granted_total": 2000, "expires_at": clock() + LEASE_DURATION}} + ) + result = await flow.check(usage=90) # 900 credits, more than the 500 left + + assert result.allowed is True + assert result.reservation is not None + assert len(flow.wire.extend_calls) == 1 + assert await flow.remaining() == 600 + + async def test_denies_when_the_retry_after_a_failed_extend_is_still_short(self, clock: VirtualClock) -> None: + flow = make_flow(clock) + await flow.check() + flow.wire.extend_responses.append({"error": "wire down"}) + result = await flow.check(usage=90) + + assert result.allowed is False + assert result.reason == "insufficient_lease_balance" + assert result.error == "insufficient_lease_balance" + assert await flow.remaining() == 500 + assert await flow.reservations.count() == 1 + + + class TestEntityResolution: + async def test_threads_a_resolved_user_into_both_evaluations(self, clock: VirtualClock) -> None: + user = RulesengineUser( + id="user_1", account_id="acc_1", environment_id="env_1", keys={"id": "user_1"}, traits=[], rules=[] + ) + flow = make_flow(clock, user=user) + result = await check_with_lease( + flow.deps, + FLAG_KEY, + COMPANY, + {"id": "user_1"}, + CheckOptions(usage=50, event_subtype=EVENT_SUBTYPE), + flow.fallback, + ) + await flow.manager._drain_background() + + assert result.allowed is True + # Evaluating without the named user would silently skip user-targeted + # rules and overrides. + assert [call["user"] for call in flow.engine.calls] == [user, user] + + async def test_falls_back_when_the_user_cannot_be_resolved(self, clock: VirtualClock) -> None: + flow = make_flow(clock, user_error=RuntimeError("DataStream client is not connected")) + result = await check_with_lease( + flow.deps, + FLAG_KEY, + COMPANY, + {"id": "user_1"}, + CheckOptions(usage=50, event_subtype=EVENT_SUBTYPE), + flow.fallback, + ) + + assert flow.fallback.called is True + assert result.reservation is None + assert flow.wire.acquire_calls == [] + + async def test_falls_back_when_the_company_cannot_be_resolved(self, clock: VirtualClock) -> None: + flow = make_flow(clock, company_error=RuntimeError("DataStream client is not connected")) + result = await flow.check() + + assert flow.fallback.called is True + assert result.reservation is None + assert flow.wire.acquire_calls == [] + + async def test_falls_back_without_datastream(self, clock: VirtualClock) -> None: + flow = make_flow(clock) + flow.deps.datastream = None + result = await flow.check() + + assert flow.fallback.called is True + assert result.reservation is None + + + class TestStoreFailureContainment: + async def test_a_dead_store_resolves_fail_closed_rather_than_raising(self, clock: VirtualClock) -> None: + flow = make_flow(clock, lease_store=UnreachableLeaseStore(clock=clock)) + result = await flow.check() + + assert result.allowed is False + assert result.reason == "lease_store_error" + assert result.error == "lease_store_error" + assert result.reservation is None + + async def test_a_dead_store_honors_fail_open(self, clock: VirtualClock) -> None: + flow = make_flow(clock, lease_store=UnreachableLeaseStore(clock=clock)) + result = await flow.check(on_acquire_failure="fail-open") + + assert result.allowed is True + assert result.reservation is None + assert flow.engine.balance(1) == FAIL_OPEN_BALANCE + + + class TestCrashWindow: + async def test_the_debit_lands_before_the_record(self, clock: VirtualClock) -> None: + """A crash in the gap leaks the debit; it never leaves a record with no + debit, which a later consume would refund into a double spend.""" + flow = make_flow(clock) + seen: Dict[str, Any] = {} + + async def freeze(reservation: ReservationRecord) -> None: + # Freeze the flow where a process death would: reached, never done, + # so neither the persist nor the undo runs. + seen["record"] = reservation + seen["remaining"] = await flow.leases.get(COMPANY["id"], CREDIT_ID) + seen["reserved"] = await flow.reservations.reserved_credits(COMPANY["id"], CREDIT_ID) + raise asyncio.CancelledError() + + flow.reservations.add = freeze # type: ignore[method-assign] + with pytest.raises(asyncio.CancelledError): + await flow.check() + + assert seen["record"].credits_reserved == 500 + assert seen["remaining"].local_remaining_credits == 500 + # Nothing the sweeper could refund: the leak is bounded by this one + # hold and reclaimed when the lease expires server-side. + assert seen["reserved"] == 0 + assert await flow.reservations.count() == 0 + assert flow.fallback.called is False + + + class TestSettle: + async def _reservation(self, flow: Flow) -> Reservation: + result = await flow.check() + assert result.reservation is not None + return result.reservation + + async def test_underuse_refunds_the_unspent_slice(self, clock: VirtualClock) -> None: + flow = make_flow(clock) + reservation = await self._reservation(flow) + outcome = await consume_reservation_and_build_event(flow.reservations, reservation, 20) + + assert outcome.settled_locally is True + assert outcome.track.event == EVENT_SUBTYPE + assert outcome.track.quantity == 20 + assert outcome.track.lease_id == "lse_1" + assert outcome.track.reservation_id is None + assert outcome.track.company == COMPANY + assert await flow.remaining() == 800 + + async def test_overuse_bills_the_actual_but_clamps_the_local_debit(self, clock: VirtualClock) -> None: + flow = make_flow(clock) + reservation = await self._reservation(flow) + outcome = await consume_reservation_and_build_event(flow.reservations, reservation, 120) + + # The server is the source of truth for real consumption, so the event + # bills the unclamped quantity; only the lease's own view is clamped. + assert outcome.track.quantity == 120 + assert await flow.remaining() == 500 + + async def test_a_settle_after_the_sweep_is_a_recovery_emit(self, clock: VirtualClock) -> None: + flow = make_flow(clock) + reservation = await self._reservation(flow) + clock.advance_ms(60_001) + assert await flow.reservations.sweep_expired() == 1 + assert await flow.remaining() == LEASE_SIZE + + outcome = await consume_reservation_and_build_event(flow.reservations, reservation, 20) + + # The hold was already refunded, so nothing re-debits the consumed + # slice and the local balance reads high until the lease rolls over. + assert outcome.settled_locally is False + assert outcome.track.quantity == 20 + assert outcome.track.lease_id == "lse_1" + assert await flow.remaining() == LEASE_SIZE + + async def test_a_second_settle_finds_nothing_to_claim(self, clock: VirtualClock) -> None: + flow = make_flow(clock) + reservation = await self._reservation(flow) + await consume_reservation_and_build_event(flow.reservations, reservation, 20) + outcome = await consume_reservation_and_build_event(flow.reservations, reservation, 20) + + assert outcome.settled_locally is False + assert await flow.remaining() == 800 + + async def test_a_fractional_settle_bills_a_whole_unit(self, clock: VirtualClock) -> None: + flow = make_flow(clock) + reservation = await self._reservation(flow) + outcome = await consume_reservation_and_build_event(flow.reservations, reservation, 0.5) + + assert outcome.track.quantity == 1 + + + class TestFlagCheckEvents: + async def test_an_allowed_check_reports_the_engine_verdict(self, clock: VirtualClock) -> None: + flow = make_flow(clock) + result = await flow.check() + + assert len(flow.flag_checks.events) == 1 + event = flow.flag_checks.events[0] + assert event.flag_key == FLAG_KEY + assert event.value is True + assert event.reason == result.reason + assert event.flag_id == "flag_1" + assert event.company_id == COMPANY["id"] + assert event.req_company == COMPANY + assert event.error is None + + async def test_a_denied_check_reports_the_denial(self, clock: VirtualClock) -> None: + flow = make_flow(clock, engine=FlowEngine(gate=_verdict(False, "denied_by_targeting"))) + await flow.check() + + assert len(flow.flag_checks.events) == 1 + assert flow.flag_checks.events[0].value is False + assert flow.flag_checks.events[0].reason == "denied_by_targeting" + + async def test_a_lease_failure_reports_once(self, clock: VirtualClock) -> None: + flow = make_flow(clock, acquire="error") + await flow.check(on_acquire_failure="fail-closed") + + assert len(flow.flag_checks.events) == 1 + assert flow.flag_checks.events[0].value is False + assert flow.flag_checks.events[0].error == "lease_acquire_failed" + + async def test_a_fallback_exit_reports_nothing(self, clock: VirtualClock) -> None: + # The plain check the lease path defers to reports its own. + flow = make_flow(clock) + await flow.check(usage=0) + + assert flow.fallback.called is True + assert flow.flag_checks.events == [] + + async def test_a_reporting_failure_leaves_the_verdict_alone(self, clock: VirtualClock) -> None: + flow = make_flow(clock) + flow.flag_checks.explode = True + result = await flow.check() + + assert result.allowed is True + assert result.reservation is not None + assert await flow.remaining() == 500 + + + class TestPerCheckTimeout: + async def test_the_timeout_reaches_the_acquire(self, clock: VirtualClock) -> None: + flow = make_flow(clock) + await flow.check(timeout=2.5) + + assert flow.wire.acquire_calls[0]["timeout"] == 2.5 + + async def test_the_timeout_reaches_the_extend_the_reserve_triggers(self, clock: VirtualClock) -> None: + flow = make_flow(clock) + await flow.check() # draws the lease down to 500 + flow.wire.extend_responses.append( + {"lease": {"granted_total": 2000, "expires_at": clock() + LEASE_DURATION}} + ) + await flow.check(usage=90, timeout=2.5) # 900 credits, more than the 500 left + + assert flow.wire.extend_calls[0]["timeout"] == 2.5 + + async def test_no_timeout_leaves_the_wire_calls_alone(self, clock: VirtualClock) -> None: + flow = make_flow(clock) + await flow.check() + + assert flow.wire.acquire_calls[0]["timeout"] is None + tests/leases/test_crash_windows.py: | + """The two bounded-leak windows, on both backends. + + A crash between the two steps of a transition must strand locally held credits + (which the server reclaims at lease expiry) rather than enable a double-spend. + The debit and the claim are durable first; the record and the refund are what + may be lost. + """ + + from __future__ import annotations + + from typing import Any, Awaitable, Tuple, cast + + import pytest + from lease_support import CrashingRefundLeaseStore, VirtualClock, make_fake_redis, make_reservation + + from schematic.leases import ( + InMemoryLeaseStore, + InMemoryReservationStore, + LeaseStore, + RedisLeaseStore, + RedisReservationStore, + ReservationStore, + ) + + BACKENDS = ("in_memory", "redis") + + + @pytest.fixture(autouse=True) + def _frozen(frozen_clock: VirtualClock) -> VirtualClock: + """fakeredis reads TIME from the process clock, so the virtual clock is it.""" + return frozen_clock + + + def _make_stores( + backend: str, clock: VirtualClock + ) -> Tuple[LeaseStore, ReservationStore, CrashingRefundLeaseStore]: + leases: LeaseStore + reservations: ReservationStore + if backend == "in_memory": + leases = InMemoryLeaseStore(clock=clock) + crash = CrashingRefundLeaseStore(leases) + reservations = InMemoryReservationStore(crash, clock=clock) + else: + client = make_fake_redis() + leases = RedisLeaseStore(client, clock=clock) + crash = CrashingRefundLeaseStore(leases) + reservations = RedisReservationStore(client, crash, clock=clock) + return leases, reservations, crash + + + async def _seed(leases: LeaseStore, clock: VirtualClock, ttl: float = 60) -> None: + await leases.replace( + lease_id="lse_1", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=1000, + expires_at=clock() + ttl, + ) + + + async def _balance(leases: LeaseStore) -> float: + entry = await leases.get("co_1", "ct_1") + assert entry is not None + return entry.local_remaining_credits + + + @pytest.mark.parametrize("backend", BACKENDS) + async def test_debit_without_record_leaks_only_the_hold(backend: str, frozen_clock: VirtualClock) -> None: + leases, reservations, _crash = _make_stores(backend, frozen_clock) + await _seed(leases, frozen_clock) + + # The crash: the atomic debit landed, the reservation record never did. + assert await leases.try_reserve("co_1", "ct_1", 100) == 900 + + # Exactly the reserved amount is stranded, and it is invisible to the + # reservation table, so no sweep can ever refund it. + assert await _balance(leases) == 900 + assert await reservations.reserved_credits("co_1", "ct_1") == 0 + assert await reservations.sweep_expired(frozen_clock() + 3600) == 0 + assert await _balance(leases) == 900 + + + @pytest.mark.parametrize("backend", BACKENDS) + async def test_debit_leak_is_reclaimed_at_lease_expiry(backend: str, frozen_clock: VirtualClock) -> None: + leases, _reservations, _crash = _make_stores(backend, frozen_clock) + await _seed(leases, frozen_clock) + assert await leases.try_reserve("co_1", "ct_1", 100) == 900 + + frozen_clock.advance_ms(60_001) + # The stale balance is never served again, and the successor installs at + # the full grant: the leak does not outlive the lease. + assert await leases.try_reserve("co_1", "ct_1", 1) is None + assert await leases.replace( + lease_id="lse_2", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=1000, + expires_at=frozen_clock() + 120, + ) + assert await _balance(leases) == 1000 + + + @pytest.mark.parametrize("backend", BACKENDS) + async def test_a_retried_check_settles_independently(backend: str, frozen_clock: VirtualClock) -> None: + leases, reservations, _crash = _make_stores(backend, frozen_clock) + await _seed(leases, frozen_clock, ttl=3600) + # The crashed attempt, then the retry with a fresh reservation. + assert await leases.try_reserve("co_1", "ct_1", 100) == 900 + assert await leases.try_reserve("co_1", "ct_1", 100) == 800 + await reservations.add(make_reservation(id="res_retry", expires_at=frozen_clock() + 60)) + + assert await reservations.consume("res_retry", 40) == 40 + # 1000 less the 100 leaked and the 40 consumed: the retry's unspent 60 came + # back exactly once, the leaked 100 stayed leaked. + assert await _balance(leases) == 860 + + assert await reservations.consume("res_retry", 40) is None + assert await reservations.sweep_expired(frozen_clock() + 3600) == 0 + assert await _balance(leases) == 860 + + + @pytest.mark.parametrize("backend", BACKENDS) + async def test_crash_before_refund_keeps_the_claim(backend: str, frozen_clock: VirtualClock) -> None: + leases, reservations, crash = _make_stores(backend, frozen_clock) + await _seed(leases, frozen_clock) + assert await leases.try_reserve("co_1", "ct_1", 100) == 900 + await reservations.add(make_reservation(expires_at=frozen_clock() + 60)) + + crash.arm() + with pytest.raises(RuntimeError, match="simulated crash before refund"): + await reservations.consume("res_1", 30) + + # The claim survived, so the reservation is gone everywhere and nothing can + # double-spend; the 70-credit refund is lost, bounded by the hold. + assert await reservations.get("res_1") is None + assert await reservations.reserved_credits("co_1", "ct_1") == 0 + assert await _balance(leases) == 900 + + # Neither a retried settle nor the sweeper can refund a claimed hold. + assert await reservations.consume("res_1", 30) is None + assert await reservations.sweep_expired(frozen_clock() + 3600) == 0 + assert await _balance(leases) == 900 + + + @pytest.mark.parametrize("backend", BACKENDS) + async def test_crash_before_refund_never_leaks_into_a_successor( + backend: str, frozen_clock: VirtualClock + ) -> None: + leases, reservations, crash = _make_stores(backend, frozen_clock) + await _seed(leases, frozen_clock) + assert await leases.try_reserve("co_1", "ct_1", 100) == 900 + await reservations.add(make_reservation(expires_at=frozen_clock() + 60)) + + crash.arm() + with pytest.raises(RuntimeError, match="simulated crash before refund"): + await reservations.consume("res_1", 30) + + frozen_clock.advance_ms(60_001) + assert await leases.try_reserve("co_1", "ct_1", 1) is None + assert await leases.replace( + lease_id="lse_2", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=1000, + expires_at=frozen_clock() + 120, + ) + # A very late retried settle must not push the lost refund into lse_2. + assert await reservations.consume("res_1", 0) is None + assert await _balance(leases) == 1000 + + + async def test_crash_after_the_claim_is_reconciled_without_a_refund(frozen_clock: VirtualClock) -> None: + # Redis only: a process death after the claim but before the index cleanup + # leaves the per-tenant index over-counting. The sweeper reconciles it, and + # deliberately does not refund: without the reservation hash, exactly-once + # cannot be arbitrated across racing sweepers. + client = make_fake_redis() + leases = RedisLeaseStore(client, clock=frozen_clock) + reservations = RedisReservationStore(client, leases, clock=frozen_clock) + await _seed(leases, frozen_clock) + assert await leases.try_reserve("co_1", "ct_1", 100) == 900 + await reservations.add(make_reservation(expires_at=frozen_clock() - 0.001)) + + original_evalsha = client.evalsha + armed = True + + async def crash_after_claim(sha: str, numkeys: int, *args: Any) -> Any: + nonlocal armed + result = await cast(Awaitable[Any], original_evalsha(sha, numkeys, *args)) + if armed and isinstance(result, list): + armed = False + raise RuntimeError("simulated crash after claim") + return result + + client.evalsha = crash_after_claim # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="simulated crash after claim"): + await reservations.consume("res_1", 30) + client.evalsha = original_evalsha # type: ignore[method-assign] + + # The orphaned index field still counts the hold... + assert await reservations.reserved_credits("co_1", "ct_1") == 100 + # ...until the sweeper reconciles it, without refunding. + assert await reservations.sweep_expired() == 0 + assert await reservations.reserved_credits("co_1", "ct_1") == 0 + assert await reservations.count() == 0 + assert await _balance(leases) == 900 + tests/leases/test_lease_manager.py: | + """Lease manager behavior, ported from the Node SDK's manager tests. + + The cross-pod cases share one Redis between two managers, which is the shape + the store's convergence rules exist for. + """ + + from __future__ import annotations + + import asyncio + from typing import Any, List, Optional + + import pytest + from lease_support import ScriptedWireClient, VirtualClock, make_fake_redis + + from schematic.leases import ( + InMemoryLeaseStore, + InMemoryReservationStore, + LeaseConfig, + LeaseGrant, + LeaseManager, + LeaseState, + LeaseStore, + RedisLeaseStore, + ) + + CONFIG = LeaseConfig(lease_duration=300, reservation_ttl=60, lease_size=1000, low_water_mark=0.25) + + + @pytest.fixture(autouse=True) + def _frozen(frozen_clock: VirtualClock) -> VirtualClock: + """The shared-store cases run against fakeredis, which reads TIME from the + process clock, so the virtual clock has to be that clock here.""" + return frozen_clock + + + def _lease(clock: VirtualClock, lease_id: str = "lse_1", granted: float = 1000, ttl: float = 300) -> dict: + return {"lease": {"lease_id": lease_id, "granted_amount": granted, "expires_at": clock() + ttl}} + + + def _make_manager(clock: VirtualClock) -> tuple[LeaseManager, InMemoryLeaseStore, ScriptedWireClient]: + store = InMemoryLeaseStore(clock=clock) + wire = ScriptedWireClient() + manager = LeaseManager(wire, store, config=CONFIG, clock=clock) + return manager, store, wire + + + async def test_acquire_installs_the_lease(clock: VirtualClock) -> None: + manager, store, wire = _make_manager(clock) + wire.acquire_responses.append(_lease(clock)) + + entry = await manager.acquire_if_needed("co_1", "ct_1") + assert len(wire.acquire_calls) == 1 + assert wire.acquire_calls[0]["requested_amount"] == 1000 + assert entry is not None and entry.lease_id == "lse_1" and entry.local_remaining_credits == 1000 + + + async def test_acquire_reuses_a_live_lease(clock: VirtualClock) -> None: + manager, _store, wire = _make_manager(clock) + wire.acquire_responses.append(_lease(clock)) + await manager.acquire_if_needed("co_1", "ct_1") + await manager.acquire_if_needed("co_1", "ct_1") + assert len(wire.acquire_calls) == 1 + + + async def test_acquire_is_single_flight(clock: VirtualClock) -> None: + manager, _store, wire = _make_manager(clock) + gate: "asyncio.Future[None]" = asyncio.get_running_loop().create_future() + original = wire.acquire + + async def slow_acquire(*args: Any, **kwargs: Any) -> LeaseGrant: + await gate + return await original(*args, **kwargs) + + wire.acquire = slow_acquire # type: ignore[method-assign] + wire.acquire_responses.append(_lease(clock)) + + pending = [asyncio.ensure_future(manager.acquire_if_needed("co_1", "ct_1")) for _ in range(3)] + await asyncio.sleep(0) + gate.set_result(None) + results = await asyncio.gather(*pending) + + assert len(wire.acquire_calls) == 1 + assert [r.lease_id for r in results if r] == ["lse_1"] * 3 + + + async def test_acquire_replaces_an_expired_slot_without_releasing(clock: VirtualClock) -> None: + manager, store, wire = _make_manager(clock) + await store.replace( + lease_id="lse_stale", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=1000, + expires_at=clock() - 1, + ) + wire.acquire_responses.append(_lease(clock, "lse_fresh")) + + entry = await manager.acquire_if_needed("co_1", "ct_1") + await manager._drain_background() + assert entry is not None and entry.lease_id == "lse_fresh" and entry.local_remaining_credits == 1000 + # `replace` wrote rather than keeping a live lease, so nothing is redundant. + assert wire.release_calls == [] + + + async def test_extend_fires_below_the_water_mark(clock: VirtualClock) -> None: + manager, store, wire = _make_manager(clock) + wire.acquire_responses.append(_lease(clock)) + await manager.acquire_if_needed("co_1", "ct_1") + await store.try_reserve("co_1", "ct_1", 800) + + wire.extend_responses.append({"lease": {"granted_total": 2000, "expires_at": clock() + 600}}) + await manager.maybe_extend("co_1", "ct_1") + assert len(wire.extend_calls) == 1 + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.granted_amount == 2000 and entry.local_remaining_credits == 1200 + + + async def test_extend_fires_for_required_credits_above_the_water_mark(clock: VirtualClock) -> None: + manager, store, wire = _make_manager(clock) + wire.acquire_responses.append(_lease(clock)) + await manager.acquire_if_needed("co_1", "ct_1") + await store.try_reserve("co_1", "ct_1", 100) + + # Without the hint this is a no-op: 900/1000 is far above the water mark. + await manager.maybe_extend("co_1", "ct_1") + assert wire.extend_calls == [] + + wire.extend_responses.append({"lease": {"granted_total": 2000, "expires_at": clock() + 600}}) + await manager.maybe_extend("co_1", "ct_1", 1500) + assert len(wire.extend_calls) == 1 + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.granted_amount == 2000 + + + async def test_extend_is_sized_to_the_shortfall(clock: VirtualClock) -> None: + manager, store, wire = _make_manager(clock) + wire.acquire_responses.append(_lease(clock)) + await manager.acquire_if_needed("co_1", "ct_1") + await store.try_reserve("co_1", "ct_1", 100) + + # A check needing 5000 credits has a 4100 shortfall, above the configured + # 1000 tranche: a tranche-sized extend would leave its retry failing + # forever however much balance the server has. + wire.extend_responses.append({"lease": {"granted_total": 5100, "expires_at": clock() + 600}}) + await manager.maybe_extend("co_1", "ct_1", 5000) + assert wire.extend_calls[-1]["additional_amount"] == 4100 + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.local_remaining_credits == 5000 + + + async def test_never_extends_an_expired_lease(clock: VirtualClock) -> None: + manager, store, wire = _make_manager(clock) + await store.replace( + lease_id="lse_old", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=1000, + expires_at=clock() - 1, + ) + assert await manager.maybe_extend("co_1", "ct_1", 1500) is None + assert wire.extend_calls == [] + + + async def test_store_failures_resolve_to_no_lease(clock: VirtualClock) -> None: + class BrokenStore(InMemoryLeaseStore): + async def get(self, company_id: str, credit_type_id: str) -> Optional[LeaseState]: + raise RuntimeError("redis down") + + wire = ScriptedWireClient() + manager = LeaseManager(wire, BrokenStore(clock=clock), config=CONFIG, clock=clock) + # Both are often called fire-and-forget, where a raised exception would + # surface as an unretrieved task exception. + assert await manager.acquire_if_needed("co_1", "ct_1") is None + assert await manager.maybe_extend("co_1", "ct_1") is None + assert wire.acquire_calls == [] + assert wire.extend_calls == [] + + + async def test_wire_failures_resolve_to_no_lease(clock: VirtualClock) -> None: + manager, store, wire = _make_manager(clock) + wire.acquire_responses.append({"error": "wire down"}) + assert await manager.acquire_if_needed("co_1", "ct_1") is None + assert await store.get("co_1", "ct_1") is None + + await store.replace( + lease_id="lse_1", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=1000, + expires_at=clock() + 300, + ) + await store.try_reserve("co_1", "ct_1", 800) + wire.extend_responses.append({"error": "wire down"}) + assert await manager.maybe_extend("co_1", "ct_1") is None + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.granted_amount == 1000 and entry.local_remaining_credits == 200 + + + async def test_release_all_releases_live_and_skips_expired(clock: VirtualClock) -> None: + manager, store, wire = _make_manager(clock) + await store.replace( + lease_id="lse_live", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=1000, + expires_at=clock() + 60, + ) + await store.replace( + lease_id="lse_expired", + company_id="co_2", + credit_type_id="ct_1", + granted_amount=1000, + expires_at=clock() - 1, + ) + await manager.release_all_local_leases() + assert wire.release_calls == ["lse_live"] + # The released lease is dropped locally; the expired one is left to lazy + # expiry. + assert await store.get("co_1", "ct_1") is None + assert await store.get("co_2", "ct_1") is not None + + + async def test_release_all_skips_a_shared_store(clock: VirtualClock) -> None: + # A shared backend cannot enumerate: sibling pods still draw on its leases. + client = make_fake_redis() + store = RedisLeaseStore(client, clock=clock) + wire = ScriptedWireClient() + manager = LeaseManager(wire, store, config=CONFIG, clock=clock) + await store.replace( + lease_id="lse_shared", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=1000, + expires_at=clock() + 60, + ) + await manager.release_all_local_leases() + assert wire.release_calls == [] + + + async def test_acquire_and_extend_do_not_share_inflight_state(clock: VirtualClock) -> None: + manager, store, wire = _make_manager(clock) + gate: "asyncio.Future[None]" = asyncio.get_running_loop().create_future() + original_extend = wire.extend + + async def slow_extend(*args: Any, **kwargs: Any) -> LeaseGrant: + await gate + return await original_extend(*args, **kwargs) + + wire.extend = slow_extend # type: ignore[method-assign] + wire.extend_responses.append({"lease": {"granted_total": 2000, "expires_at": clock() + 600}}) + wire.acquire_responses.append(_lease(clock, "lse_fresh")) + + await store.replace( + lease_id="lse_live", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=1000, + expires_at=clock() + 300, + ) + await store.try_reserve("co_1", "ct_1", 800) + extending = asyncio.ensure_future(manager.maybe_extend("co_1", "ct_1")) + await asyncio.sleep(0) + + await store.drop("co_1", "ct_1") + acquired = await manager.acquire_if_needed("co_1", "ct_1") + assert acquired is not None and acquired.lease_id == "lse_fresh" + assert len(wire.acquire_calls) == 1 + + gate.set_result(None) + await extending + + + async def test_lost_acquire_race_releases_the_redundant_lease(clock: VirtualClock) -> None: + # Two managers on one Redis, as two pods would be. Both see an empty slot + # and acquire; only one lease can hold the slot, and the loser must release + # the one it minted rather than orphan it against the company balance. + shared: LeaseStore = RedisLeaseStore(make_fake_redis(), clock=clock) + pods = [] + for lease_id in ("lse_a", "lse_b"): + wire = ScriptedWireClient() + wire.acquire_responses.append(_lease(clock, lease_id)) + pods.append((LeaseManager(wire, shared, config=CONFIG, clock=clock), wire)) + + entries = await asyncio.gather(*(manager.acquire_if_needed("co_1", "ct_1") for manager, _ in pods)) + for manager, _ in pods: + await manager._drain_background() + + survivor = await shared.get("co_1", "ct_1") + assert survivor is not None and survivor.lease_id in ("lse_a", "lse_b") + assert [entry.lease_id for entry in entries if entry] == [survivor.lease_id] * 2 + + released: List[str] = [lease_id for _, wire in pods for lease_id in wire.release_calls] + loser = "lse_b" if survivor.lease_id == "lse_a" else "lse_a" + assert released == [loser] + + + async def test_lost_acquire_race_with_the_same_lease_releases_nothing(clock: VirtualClock) -> None: + # The server is idempotent for an active slot, so a racing acquire is + # handed back the SAME lease the sibling installed. Releasing it would pull + # the shared lease out from under every pod. + shared: LeaseStore = RedisLeaseStore(make_fake_redis(), clock=clock) + pods = [] + for _ in range(2): + wire = ScriptedWireClient() + wire.acquire_responses.append(_lease(clock, "lse_shared")) + pods.append((LeaseManager(wire, shared, config=CONFIG, clock=clock), wire)) + + entries = await asyncio.gather(*(manager.acquire_if_needed("co_1", "ct_1") for manager, _ in pods)) + for manager, _ in pods: + await manager._drain_background() + + assert [entry.lease_id for entry in entries if entry] == ["lse_shared", "lse_shared"] + assert [lease_id for _, wire in pods for lease_id in wire.release_calls] == [] + + + async def test_uncontended_acquire_releases_nothing(clock: VirtualClock) -> None: + manager, _store, wire = _make_manager(clock) + wire.acquire_responses.append(_lease(clock)) + await manager.acquire_if_needed("co_1", "ct_1") + await manager._drain_background() + assert wire.release_calls == [] + + + async def test_sweep_loop_runs_and_stops(clock: VirtualClock) -> None: + leases = InMemoryLeaseStore(clock=clock) + reservations = InMemoryReservationStore(leases, clock=clock) + manager = LeaseManager( + ScriptedWireClient(), + leases, + reservation_store=reservations, + # Short enough that the test does not idle, long enough that the loop + # cannot run twice before it is stopped. + config=LeaseConfig(sweep_interval=0.01), + clock=clock, + ) + swept: List[int] = [] + original = reservations.sweep_expired + + async def counting_sweep(now: Optional[float] = None) -> int: + result = await original(now) + swept.append(result) + return result + + reservations.sweep_expired = counting_sweep # type: ignore[method-assign] + + manager.start_sweep() + manager.start_sweep() # idempotent + await asyncio.sleep(0.03) + assert swept + manager.stop() + ticks = len(swept) + await asyncio.sleep(0.03) + assert len(swept) == ticks + + + async def test_sweep_loop_survives_a_failing_sweep(clock: VirtualClock) -> None: + leases = InMemoryLeaseStore(clock=clock) + reservations = InMemoryReservationStore(leases, clock=clock) + manager = LeaseManager( + ScriptedWireClient(), + leases, + reservation_store=reservations, + config=LeaseConfig(sweep_interval=0.01), + clock=clock, + ) + attempts: List[int] = [] + + async def failing_sweep(now: Optional[float] = None) -> int: + attempts.append(1) + raise RuntimeError("redis blip") + + reservations.sweep_expired = failing_sweep # type: ignore[method-assign] + manager.start_sweep() + await asyncio.sleep(0.05) + manager.stop() + # A transient failure must not kill the loop: the next tick retries. + assert len(attempts) > 1 + + + async def test_resolve_config_applies_overrides(clock: VirtualClock) -> None: + from schematic.leases import LeaseConfigOverride + + config = LeaseConfig( + lease_size=500, + overrides={"ct_special": LeaseConfigOverride(lease_size=25, low_water_mark=0.5)}, + ) + manager = LeaseManager(ScriptedWireClient(), InMemoryLeaseStore(clock=clock), config=config, clock=clock) + + plain = manager.resolve_config("ct_1") + assert plain.lease_size == 500 + assert plain.low_water_mark == 0.25 + assert plain.lease_duration == 300 + + special = manager.resolve_config("ct_special") + assert special.lease_size == 25 + assert special.low_water_mark == 0.5 + + + def test_resolve_lease_config_leaves_the_reservation_ttl_alone() -> None: + from schematic.leases import resolve_lease_config + + resolved = resolve_lease_config(LeaseConfig(reservation_ttl=7200.0), None, "ct_1") + # A client-mode TTL never reaches the server, so the server's cap does not + # apply to it: it only tells the local sweeper when to refund a hold. + assert resolved.reservation_ttl == 7200.0 + tests/leases/test_lease_store.py: | + """In-memory lease slot semantics, ported from the Node SDK's store tests.""" + + from __future__ import annotations + + import asyncio + import math + + import pytest + from lease_support import VirtualClock + + from schematic.leases import InMemoryLeaseStore + + + async def _seed(store: InMemoryLeaseStore, clock: VirtualClock, *, granted: float = 100, ttl: float = 60) -> None: + await store.replace( + lease_id="lse_1", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=granted, + expires_at=clock() + ttl, + ) + + + @pytest.fixture + def store(clock: VirtualClock) -> InMemoryLeaseStore: + return InMemoryLeaseStore(clock=clock) + + + async def test_replace_installs_at_the_full_grant(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + await _seed(store, clock) + entry = await store.get("co_1", "ct_1") + assert entry is not None + assert entry.granted_amount == 100 + assert entry.local_remaining_credits == 100 + + + async def test_try_reserve_returns_the_post_debit_balance(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + await _seed(store, clock) + assert await store.try_reserve("co_1", "ct_1", 30) == 70 + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.local_remaining_credits == 70 + + + async def test_try_reserve_refuses_without_debiting(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + await _seed(store, clock) + assert await store.try_reserve("co_1", "ct_1", 150) is None + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.local_remaining_credits == 100 + + + async def test_try_reserve_refuses_an_expired_lease(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + await _seed(store, clock) + clock.advance_ms(60_001) + assert await store.try_reserve("co_1", "ct_1", 10) is None + # The balance is stale, not spendable: the server released the lease and + # refunded its remainder. + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.local_remaining_credits == 100 + + + async def test_try_reserve_rejects_nan_and_infinity(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + await _seed(store, clock) + # NaN slips through every comparison, so an unguarded debit would set the + # balance to NaN and approve every later reserve. + assert await store.try_reserve("co_1", "ct_1", math.nan) is None + assert await store.try_reserve("co_1", "ct_1", -10) is None + assert await store.try_reserve("co_1", "ct_1", math.inf) is None + assert await store.try_reserve("co_1", "ct_1", 30) == 70 + assert await store.try_reserve("co_1", "ct_1", 80) is None + + + async def test_refund_caps_at_the_granted_amount(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + await _seed(store, clock) + await store.try_reserve("co_1", "ct_1", 30) + await store.refund("co_1", "ct_1", 20) + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.local_remaining_credits == 90 + await store.refund("co_1", "ct_1", 9999) + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.local_remaining_credits == 100 + + + async def test_refund_pinned_to_a_stale_lease_is_dropped(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + await store.replace( + lease_id="lse_b", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=100, + expires_at=clock() + 60, + ) + await store.try_reserve("co_1", "ct_1", 50) + await store.refund("co_1", "ct_1", 30, "lse_a") + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.local_remaining_credits == 50 + await store.refund("co_1", "ct_1", 30, "lse_b") + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.local_remaining_credits == 80 + + + async def test_concurrent_try_reserves_serialize_per_slot(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + # Per-slot atomicity: three concurrent 40-credit reserves against a + # 100-credit lease must not oversell it. + await _seed(store, clock) + results = await asyncio.gather( + store.try_reserve("co_1", "ct_1", 40), + store.try_reserve("co_1", "ct_1", 40), + store.try_reserve("co_1", "ct_1", 40), + ) + assert sorted(r for r in results if r is not None) == [20, 60] + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.local_remaining_credits == 20 + + + async def test_extend_reconciles_to_the_server_total(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + await _seed(store, clock, ttl=10) + await store.try_reserve("co_1", "ct_1", 30) + new_expiry = clock() + 60 + await store.extend("co_1", "ct_1", 150, new_expiry) + entry = await store.get("co_1", "ct_1") + assert entry is not None + assert entry.granted_amount == 150 + assert entry.local_remaining_credits == 120 + assert entry.expires_at == new_expiry + + + async def test_stale_extend_total_is_a_no_op(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + await _seed(store, clock, ttl=10) + far_expiry = clock() + 120 + await store.extend("co_1", "ct_1", 200, far_expiry) + await store.extend("co_1", "ct_1", 150, clock() + 60) + entry = await store.get("co_1", "ct_1") + assert entry is not None + assert entry.granted_amount == 200 + assert entry.local_remaining_credits == 200 + assert entry.expires_at == far_expiry + + + async def test_extend_pinned_to_a_stale_lease_is_dropped(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + await store.replace( + lease_id="lse_b", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=100, + expires_at=clock() + 60, + ) + await store.extend("co_1", "ct_1", 150, clock() + 120, "lse_a") + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.granted_amount == 100 and entry.local_remaining_credits == 100 + await store.extend("co_1", "ct_1", 150, clock() + 120, "lse_b") + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.granted_amount == 150 and entry.local_remaining_credits == 150 + + + async def test_drop_removes_the_slot(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + await _seed(store, clock) + await store.drop("co_1", "ct_1") + assert await store.get("co_1", "ct_1") is None + + + async def test_replace_keeps_a_live_lease_with_a_different_id( + store: InMemoryLeaseStore, clock: VirtualClock + ) -> None: + assert await store.replace( + lease_id="lse_1", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=100, + expires_at=clock() + 60, + ) + await store.try_reserve("co_1", "ct_1", 40) + wrote = await store.replace( + lease_id="lse_2", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=100, + expires_at=clock() + 60, + ) + assert wrote is False + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.lease_id == "lse_1" and entry.local_remaining_credits == 60 + + + async def test_replace_overwrites_an_expired_lease(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + await _seed(store, clock, ttl=60) + clock.advance_ms(60_001) + wrote = await store.replace( + lease_id="lse_2", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=100, + expires_at=clock() + 60, + ) + assert wrote is True + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.lease_id == "lse_2" and entry.local_remaining_credits == 100 + + + async def test_replace_reconciles_an_expired_same_id_lease(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + # A stale acquire response can hand back the lease already installed (the + # server is idempotent for an active slot) after the local row expired. + # Rewriting would reset the balance and erase debits whose reservations are + # still open. + await _seed(store, clock, ttl=60) + await store.try_reserve("co_1", "ct_1", 40) + clock.advance_ms(61_000) + later_expiry = clock() + 60 + wrote = await store.replace( + lease_id="lse_1", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=150, + expires_at=later_expiry, + ) + assert wrote is False + entry = await store.get("co_1", "ct_1") + assert entry is not None + assert entry.granted_amount == 150 + assert entry.local_remaining_credits == 110 + assert entry.expires_at == later_expiry + + + async def test_get_returns_a_snapshot(store: InMemoryLeaseStore, clock: VirtualClock) -> None: + # Mutating what `get` handed back must not reach the stored slot. + await _seed(store, clock) + entry = await store.get("co_1", "ct_1") + assert entry is not None + entry.local_remaining_credits = 0 + stored = await store.get("co_1", "ct_1") + assert stored is not None and stored.local_remaining_credits == 100 + tests/leases/test_redis_lease_store.py: | + """Redis lease slot semantics, ported from the Node SDK's Redis store tests. + + The shared-backend cases here are the ones the vectors cannot express: two pods + on one Redis, and rows that are expired but not yet evicted. + """ + + from __future__ import annotations + + import asyncio + import math + from typing import Any + + import pytest + from lease_support import VirtualClock + + from schematic.leases import RedisLeaseStore + from schematic.leases.redis_lease_store import LEASE_TTL_GRACE_MS + + + @pytest.fixture + def store(redis_client: Any, frozen_clock: VirtualClock) -> RedisLeaseStore: + return RedisLeaseStore(redis_client, clock=frozen_clock) + + + async def _seed(store: RedisLeaseStore, clock: VirtualClock, *, granted: float = 100, ttl: float = 60) -> bool: + return await store.replace( + lease_id="lse_1", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=granted, + expires_at=clock() + ttl, + ) + + + async def test_replace_installs_a_fresh_lease(store: RedisLeaseStore, frozen_clock: VirtualClock) -> None: + assert await _seed(store, frozen_clock) is True + entry = await store.get("co_1", "ct_1") + assert entry is not None + assert entry.lease_id == "lse_1" + assert entry.granted_amount == 100 + assert entry.local_remaining_credits == 100 + + + async def test_replace_preserves_debits_when_the_same_live_lease_is_rewritten( + store: RedisLeaseStore, frozen_clock: VirtualClock + ) -> None: + await _seed(store, frozen_clock, granted=1000) + await store.try_reserve("co_1", "ct_1", 400) + # A second pod acquires and is handed the same lease back. + assert await _seed(store, frozen_clock, granted=1000) is False + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.local_remaining_credits == 600 + + + async def test_replace_reconciles_an_expired_same_id_lease( + store: RedisLeaseStore, frozen_clock: VirtualClock + ) -> None: + await _seed(store, frozen_clock, granted=1000) + await store.try_reserve("co_1", "ct_1", 400) + frozen_clock.advance_ms(61_000) + later_expiry = frozen_clock() + 60 + wrote = await store.replace( + lease_id="lse_1", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=1500, + expires_at=later_expiry, + ) + assert wrote is False + entry = await store.get("co_1", "ct_1") + assert entry is not None + assert entry.granted_amount == 1500 + assert entry.local_remaining_credits == 1100 + assert entry.expires_at == later_expiry + + + async def test_replace_keeps_a_different_live_lease(store: RedisLeaseStore, frozen_clock: VirtualClock) -> None: + # Two pods race the first acquire: the loser must not clobber the winner's + # already-debited balance. + await store.replace( + lease_id="lse_winner", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=1000, + expires_at=frozen_clock() + 60, + ) + await store.try_reserve("co_1", "ct_1", 400) + wrote = await store.replace( + lease_id="lse_loser", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=1000, + expires_at=frozen_clock() + 60, + ) + assert wrote is False + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.lease_id == "lse_winner" and entry.local_remaining_credits == 600 + + + async def test_try_reserve_gates_the_shared_balance(store: RedisLeaseStore, frozen_clock: VirtualClock) -> None: + # Three concurrent 40-credit reserves against a 100-credit lease: the Lua + # check-and-debit must not oversell it. + await _seed(store, frozen_clock) + results = await asyncio.gather( + store.try_reserve("co_1", "ct_1", 40), + store.try_reserve("co_1", "ct_1", 40), + store.try_reserve("co_1", "ct_1", 40), + ) + assert sorted(r for r in results if r is not None) == [20, 60] + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.local_remaining_credits == 20 + + + async def test_try_reserve_refuses_an_expired_but_unevicted_row( + store: RedisLeaseStore, frozen_clock: VirtualClock + ) -> None: + await _seed(store, frozen_clock, ttl=60) + # Past the declared expiry but inside the TTL grace, so the row is still + # readable; the script must still refuse it. + frozen_clock.advance_ms(60_000 + LEASE_TTL_GRACE_MS / 2) + assert await store.get("co_1", "ct_1") is not None + assert await store.try_reserve("co_1", "ct_1", 10) is None + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.local_remaining_credits == 100 + + + async def test_try_reserve_rejects_nan_before_it_reaches_the_script( + store: RedisLeaseStore, frozen_clock: VirtualClock + ) -> None: + # The string form of NaN parses back to a Lua nan and would poison the + # SHARED balance for every pod. + await _seed(store, frozen_clock) + assert await store.try_reserve("co_1", "ct_1", math.nan) is None + assert await store.try_reserve("co_1", "ct_1", -10) is None + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.local_remaining_credits == 100 + assert await store.try_reserve("co_1", "ct_1", 30) == 70 + + + async def test_fractional_credits_survive_the_round_trip( + store: RedisLeaseStore, frozen_clock: VirtualClock + ) -> None: + await _seed(store, frozen_clock, granted=10) + assert await store.try_reserve("co_1", "ct_1", 2.5) == 7.5 + await store.refund("co_1", "ct_1", 1.25) + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.local_remaining_credits == 8.75 + + + async def test_refund_caps_at_the_granted_amount(store: RedisLeaseStore, frozen_clock: VirtualClock) -> None: + await _seed(store, frozen_clock) + await store.try_reserve("co_1", "ct_1", 30) + await store.refund("co_1", "ct_1", 9999) + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.local_remaining_credits == 100 + + + async def test_refund_pinned_to_a_stale_lease_is_dropped( + store: RedisLeaseStore, frozen_clock: VirtualClock + ) -> None: + await store.replace( + lease_id="lse_b", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=100, + expires_at=frozen_clock() + 60, + ) + await store.try_reserve("co_1", "ct_1", 50) + await store.refund("co_1", "ct_1", 30, "lse_a") + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.local_remaining_credits == 50 + await store.refund("co_1", "ct_1", 30, "lse_b") + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.local_remaining_credits == 80 + + + async def test_extend_falls_back_to_the_configured_duration( + redis_client: Any, frozen_clock: VirtualClock + ) -> None: + store = RedisLeaseStore(redis_client, default_lease_duration=0.25, clock=frozen_clock) + # A short initial expiry so the fallback is a forward move. + await store.replace( + lease_id="lse_1", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=100, + expires_at=frozen_clock() + 0.1, + ) + await store.extend("co_1", "ct_1", 150) + entry = await store.get("co_1", "ct_1") + assert entry is not None + assert entry.granted_amount == 150 + assert entry.expires_at == pytest.approx(frozen_clock() + 0.25) + + + async def test_extend_pinned_to_a_stale_lease_is_dropped( + store: RedisLeaseStore, frozen_clock: VirtualClock + ) -> None: + await store.replace( + lease_id="lse_b", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=100, + expires_at=frozen_clock() + 60, + ) + await store.extend("co_1", "ct_1", 150, frozen_clock() + 120, "lse_a") + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.granted_amount == 100 and entry.local_remaining_credits == 100 + await store.extend("co_1", "ct_1", 150, frozen_clock() + 120, "lse_b") + entry = await store.get("co_1", "ct_1") + assert entry is not None and entry.granted_amount == 150 and entry.local_remaining_credits == 150 + + + async def test_concurrent_sibling_extends_converge_on_the_server_total( + redis_client: Any, frozen_clock: VirtualClock + ) -> None: + # Two pods on one Redis. Per-process single-flight cannot serialize them, + # so both wire calls go out against the same stale read (granted=100); the + # server lands B's total (150) then A's (200). Each pod applies the TOTAL + # it was handed, so the slot converges on 200, never 250. + pod_a = RedisLeaseStore(redis_client, clock=frozen_clock) + pod_b = RedisLeaseStore(redis_client, clock=frozen_clock) + await pod_a.replace( + lease_id="lse_1", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=100, + expires_at=frozen_clock() + 60, + ) + await pod_b.extend("co_1", "ct_1", 150, frozen_clock() + 90, "lse_1") + await pod_a.extend("co_1", "ct_1", 200, frozen_clock() + 120, "lse_1") + entry = await pod_a.get("co_1", "ct_1") + assert entry is not None and entry.granted_amount == 200 and entry.local_remaining_credits == 200 + + # Out-of-order arrival: the larger total lands first and the superseded one + # is a no-op that never pulls the expiry back. + await pod_a.replace( + lease_id="lse_2", + company_id="co_1", + credit_type_id="ct_2", + granted_amount=100, + expires_at=frozen_clock() + 60, + ) + far_expiry = frozen_clock() + 120 + await pod_a.extend("co_1", "ct_2", 200, far_expiry, "lse_2") + await pod_b.extend("co_1", "ct_2", 150, frozen_clock() + 90, "lse_2") + entry = await pod_a.get("co_1", "ct_2") + assert entry is not None + assert entry.granted_amount == 200 + assert entry.local_remaining_credits == 200 + assert entry.expires_at == far_expiry + + + async def test_drop_removes_the_hash(store: RedisLeaseStore, frozen_clock: VirtualClock) -> None: + await _seed(store, frozen_clock) + await store.drop("co_1", "ct_1") + assert await store.get("co_1", "ct_1") is None + + + async def test_key_layout_and_hash_fields_match_the_node_sdk( + store: RedisLeaseStore, redis_client: Any, frozen_clock: VirtualClock + ) -> None: + # Node and Python pods share one Redis, so the key, the camelCase field + # names, and the millisecond instants have to match exactly. + expires_at = frozen_clock() + 60 + await store.replace( + lease_id="lse_1", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=1000, + expires_at=expires_at, + ) + key = store.hash_key("co_1", "ct_1") + assert key == "schematic:credit-lease:co_1:ct_1" + raw = await redis_client.hgetall(key) + assert raw == { + "leaseId": "lse_1", + "companyId": "co_1", + "creditTypeId": "ct_1", + "grantedAmount": "1000", + "localRemainingCredits": "1000", + "expiresAt": str(int(round(expires_at * 1000))), + } + # The row outlives its expiry by the grace window so the sweeper can still + # read it. + ttl_ms = await redis_client.pttl(key) + assert 60_000 < ttl_ms <= 60_000 + LEASE_TTL_GRACE_MS + + + async def test_a_flushed_script_cache_falls_back_to_eval( + store: RedisLeaseStore, redis_client: Any, frozen_clock: VirtualClock + ) -> None: + # A Redis that restarts (or is flushed) loses the cached script and answers + # NOSCRIPT; the store re-sends the body rather than failing the reserve. + await _seed(store, frozen_clock) + assert await store.try_reserve("co_1", "ct_1", 10) == 90 + await redis_client.script_flush() + assert await store.try_reserve("co_1", "ct_1", 10) == 80 + tests/leases/test_redis_reservation_store.py: | + """Redis reservation table semantics, ported from the Node SDK's tests.""" + + from __future__ import annotations + + from typing import Any + + import pytest + from lease_support import VirtualClock, make_reservation + + from schematic.leases import RedisLeaseStore, RedisReservationStore + from schematic.leases.redis_reservation_store import RES_TTL_GRACE_MS, SWEEP_BATCH_SIZE + + + @pytest.fixture + def leases(redis_client: Any, frozen_clock: VirtualClock) -> RedisLeaseStore: + return RedisLeaseStore(redis_client, clock=frozen_clock) + + + @pytest.fixture + def reservations(redis_client: Any, leases: RedisLeaseStore, frozen_clock: VirtualClock) -> RedisReservationStore: + return RedisReservationStore(redis_client, leases, clock=frozen_clock) + + + @pytest.fixture(autouse=True) + async def seeded_lease(leases: RedisLeaseStore, frozen_clock: VirtualClock) -> None: + await leases.replace( + lease_id="lse_1", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=1000, + expires_at=frozen_clock() + 600, + ) + + + async def _balance(leases: RedisLeaseStore) -> float: + entry = await leases.get("co_1", "ct_1") + assert entry is not None + return entry.local_remaining_credits + + + async def test_add_round_trips_and_indexes( + reservations: RedisReservationStore, frozen_clock: VirtualClock + ) -> None: + await reservations.add(make_reservation(expires_at=frozen_clock() + 60)) + fetched = await reservations.get("res_1") + assert fetched is not None + assert fetched.credits_reserved == 100 + assert fetched.lease_id == "lse_1" + assert fetched.company == {"id": "co_1"} + assert await reservations.count() == 1 + + + async def test_consume_refunds_the_unspent_slice( + leases: RedisLeaseStore, reservations: RedisReservationStore, frozen_clock: VirtualClock + ) -> None: + await leases.try_reserve("co_1", "ct_1", 100) + await reservations.add(make_reservation(expires_at=frozen_clock() + 60)) + assert await _balance(leases) == 900 + + assert await reservations.consume("res_1", 30) == 30 + assert await _balance(leases) == 970 + assert await reservations.get("res_1") is None + + + async def test_double_consume_returns_null( + leases: RedisLeaseStore, reservations: RedisReservationStore, frozen_clock: VirtualClock + ) -> None: + await leases.try_reserve("co_1", "ct_1", 100) + await reservations.add(make_reservation(expires_at=frozen_clock() + 60)) + await reservations.consume("res_1", 50) + assert await reservations.consume("res_1", 10) is None + + + async def test_sweep_returns_expired_holds_to_the_lease( + leases: RedisLeaseStore, reservations: RedisReservationStore, frozen_clock: VirtualClock + ) -> None: + await leases.try_reserve("co_1", "ct_1", 100) + await reservations.add(make_reservation(expires_at=frozen_clock() - 0.001)) + assert await reservations.sweep_expired() == 1 + assert await _balance(leases) == 1000 + assert await reservations.get("res_1") is None + + + async def test_a_stale_lease_hold_never_inflates_its_successor( + leases: RedisLeaseStore, reservations: RedisReservationStore, frozen_clock: VirtualClock + ) -> None: + await leases.try_reserve("co_1", "ct_1", 100) + await reservations.add(make_reservation(expires_at=frozen_clock() - 0.001)) + + await leases.drop("co_1", "ct_1") + await leases.replace( + lease_id="lse_2", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=1000, + expires_at=frozen_clock() + 600, + ) + await leases.try_reserve("co_1", "ct_1", 200) + + assert await reservations.sweep_expired() == 1 + assert await _balance(leases) == 800 + assert await reservations.get("res_1") is None + + + async def test_reserved_credits_sums_open_holds( + leases: RedisLeaseStore, reservations: RedisReservationStore, frozen_clock: VirtualClock + ) -> None: + await leases.try_reserve("co_1", "ct_1", 350) + await reservations.add(make_reservation(id="res_1", credits_reserved=100, expires_at=frozen_clock() + 60)) + await reservations.add(make_reservation(id="res_2", credits_reserved=250, expires_at=frozen_clock() + 60)) + await reservations.add( + make_reservation(id="res_3", credit_type_id="ct_2", credits_reserved=999, expires_at=frozen_clock() + 60) + ) + + assert await reservations.reserved_credits("co_1", "ct_1") == 350 + assert await reservations.reserved_credits("co_1", "ct_2") == 999 + + await reservations.consume("res_1", 40) + assert await reservations.reserved_credits("co_1", "ct_1") == 250 + + + async def test_every_lua_call_touches_one_key( + redis_client: Any, leases: RedisLeaseStore, reservations: RedisReservationStore, frozen_clock: VirtualClock + ) -> None: + # A multi-key script whose keys hash to different slots raises CROSSSLOT on + # Redis Cluster, so every EVAL these stores send must touch exactly one. + key_counts = [] + original_eval = redis_client.eval + original_evalsha = redis_client.evalsha + + async def record_eval(script: str, numkeys: int, *args: Any) -> Any: + key_counts.append(numkeys) + return await original_eval(script, numkeys, *args) + + async def record_evalsha(sha: str, numkeys: int, *args: Any) -> Any: + key_counts.append(numkeys) + return await original_evalsha(sha, numkeys, *args) + + redis_client.eval = record_eval + redis_client.evalsha = record_evalsha + + await leases.try_reserve("co_1", "ct_1", 200) + await leases.refund("co_1", "ct_1", 50) + await leases.extend("co_1", "ct_1", 1100, frozen_clock() + 120) + await reservations.add(make_reservation(id="res_a", credits_reserved=100, expires_at=frozen_clock() + 60)) + await reservations.add( + make_reservation(id="res_b", credits_reserved=80, expires_at=frozen_clock() - 0.001) + ) + await reservations.consume("res_a", 40) + await reservations.sweep_expired() + + assert key_counts + assert set(key_counts) == {1} + + + async def test_sweep_reconciles_indexes_when_the_hash_has_evicted( + leases: RedisLeaseStore, reservations: RedisReservationStore, frozen_clock: VirtualClock + ) -> None: + # A sweeper that goes silent long enough (deploy, restart, starvation) for + # Redis to evict the reservation hash must still clean both indexes, or the + # orphaned entry inflates reserved_credits forever. + await leases.try_reserve("co_1", "ct_1", 100) + await reservations.add(make_reservation(expires_at=frozen_clock() + 1)) + assert await reservations.count() == 1 + assert await reservations.reserved_credits("co_1", "ct_1") == 100 + + frozen_clock.advance_ms(1000 + RES_TTL_GRACE_MS + 1) + + # Nothing is swept in the refund sense: without the hash, exactly-once + # cannot be arbitrated across racing sweepers, so the slice waits for the + # lease to expire server-side. + assert await reservations.sweep_expired() == 0 + assert await reservations.count() == 0 + assert await reservations.reserved_credits("co_1", "ct_1") == 0 + assert await _balance(leases) == 900 + + + async def test_sweep_drops_an_unparseable_index_member( + redis_client: Any, reservations: RedisReservationStore, frozen_clock: VirtualClock + ) -> None: + # Only `add` writes members, so this cannot happen; a member that does not + # decode must still be removed rather than re-read by every later sweep. + await redis_client.zadd( + "schematic:credit-reservations:byExpiry", {"garbage": int(frozen_clock() * 1000) - 1} + ) + assert await reservations.sweep_expired() == 0 + assert await reservations.count() == 0 + + + async def test_sweep_pages_through_a_backlog( + leases: RedisLeaseStore, reservations: RedisReservationStore, frozen_clock: VirtualClock + ) -> None: + count = SWEEP_BATCH_SIZE + 44 + await leases.try_reserve("co_1", "ct_1", count) + for index in range(count): + await reservations.add( + make_reservation(id=f"res_{index}", credits_reserved=1, expires_at=frozen_clock() - 0.001) + ) + assert await reservations.sweep_expired() == count + assert await _balance(leases) == 1000 + assert await reservations.count() == 0 + assert await reservations.reserved_credits("co_1", "ct_1") == 0 + + + async def test_key_layout_and_hash_fields_match_the_node_sdk( + redis_client: Any, reservations: RedisReservationStore, frozen_clock: VirtualClock + ) -> None: + expires_at = frozen_clock() + 60 + await reservations.add(make_reservation(expires_at=expires_at)) + raw = await redis_client.hgetall("schematic:credit-reservation:res_1") + assert raw == { + "id": "res_1", + "leaseId": "lse_1", + "companyId": "co_1", + "creditTypeId": "ct_1", + "eventSubtype": "inference_tokens", + "quantityReserved": "10", + "creditsReserved": "100", + "consumptionRate": "10", + "expiresAt": str(int(round(expires_at * 1000))), + "evalCtx": '{"company":{"id":"co_1"}}', + } + assert await redis_client.zrange("schematic:credit-reservations:byExpiry", 0, -1) == ["co_1|ct_1|res_1"] + assert await redis_client.hgetall("schematic:credit-reservations:byCredit:co_1:ct_1") == {"res_1": "100"} + ttl_ms = await redis_client.pttl("schematic:credit-reservation:res_1") + assert 60_000 < ttl_ms <= 60_000 + RES_TTL_GRACE_MS + tests/leases/test_reservation_store.py: | + """In-memory reservation table semantics, ported from the Node SDK's tests.""" + + from __future__ import annotations + + import pytest + from lease_support import VirtualClock, make_reservation + + from schematic.leases import InMemoryLeaseStore, InMemoryReservationStore + + + @pytest.fixture + def leases(clock: VirtualClock) -> InMemoryLeaseStore: + return InMemoryLeaseStore(clock=clock) + + + @pytest.fixture + def reservations(leases: InMemoryLeaseStore, clock: VirtualClock) -> InMemoryReservationStore: + return InMemoryReservationStore(leases, clock=clock) + + + @pytest.fixture(autouse=True) + async def seeded_lease(leases: InMemoryLeaseStore, clock: VirtualClock) -> None: + await leases.replace( + lease_id="lse_1", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=1000, + expires_at=clock() + 60, + ) + + + async def _balance(leases: InMemoryLeaseStore) -> float: + entry = await leases.get("co_1", "ct_1") + assert entry is not None + return entry.local_remaining_credits + + + async def test_consume_refunds_the_unspent_slice( + leases: InMemoryLeaseStore, reservations: InMemoryReservationStore, clock: VirtualClock + ) -> None: + await leases.try_reserve("co_1", "ct_1", 100) + await reservations.add(make_reservation(expires_at=clock() + 60)) + assert await _balance(leases) == 900 + + assert await reservations.consume("res_1", 30) == 30 + assert await _balance(leases) == 970 + assert await reservations.get("res_1") is None + + + async def test_consume_clamps_to_the_hold( + leases: InMemoryLeaseStore, reservations: InMemoryReservationStore, clock: VirtualClock + ) -> None: + await leases.try_reserve("co_1", "ct_1", 100) + await reservations.add(make_reservation(expires_at=clock() + 60)) + assert await reservations.consume("res_1", 999) == 100 + assert await _balance(leases) == 900 + + + async def test_consume_of_a_missing_reservation_is_null(reservations: InMemoryReservationStore) -> None: + assert await reservations.consume("nope", 10) is None + + + async def test_consume_is_exactly_once( + leases: InMemoryLeaseStore, reservations: InMemoryReservationStore, clock: VirtualClock + ) -> None: + await leases.try_reserve("co_1", "ct_1", 100) + await reservations.add(make_reservation(expires_at=clock() + 60)) + assert await reservations.consume("res_1", 30) == 30 + assert await reservations.consume("res_1", 30) is None + assert await _balance(leases) == 970 + + + async def test_a_stale_lease_hold_never_inflates_its_successor( + leases: InMemoryLeaseStore, reservations: InMemoryReservationStore, clock: VirtualClock + ) -> None: + await leases.try_reserve("co_1", "ct_1", 100) + await reservations.add(make_reservation(expires_at=clock() - 0.001)) + + # lse_1 expires and lse_2 takes the slot, partially debited. + await leases.drop("co_1", "ct_1") + await leases.replace( + lease_id="lse_2", + company_id="co_1", + credit_type_id="ct_1", + granted_amount=1000, + expires_at=clock() + 60, + ) + await leases.try_reserve("co_1", "ct_1", 200) + + # Sweeping lse_1's hold must not credit lse_2: that slice went back to the + # company balance when lse_1 expired server-side. + assert await reservations.sweep_expired() == 1 + assert await _balance(leases) == 800 + + # An explicit consume of a stale-lease hold is dropped the same way. + await reservations.add(make_reservation(id="res_2", expires_at=clock() + 60)) + await reservations.consume("res_2", 0) + assert await _balance(leases) == 800 + + + async def test_sweep_refunds_expired_holds_only( + leases: InMemoryLeaseStore, reservations: InMemoryReservationStore, clock: VirtualClock + ) -> None: + await leases.try_reserve("co_1", "ct_1", 100) + await reservations.add(make_reservation(expires_at=clock() + 10)) + assert await reservations.sweep_expired() == 0 + assert await reservations.count() == 1 + + clock.advance_ms(10_001) + assert await reservations.sweep_expired() == 1 + assert await reservations.get("res_1") is None + assert await _balance(leases) == 1000 + + + async def test_reserved_credits_sums_the_slot_only(reservations: InMemoryReservationStore) -> None: + await reservations.add(make_reservation(id="res_1", credits_reserved=100)) + await reservations.add(make_reservation(id="res_2", credits_reserved=250)) + await reservations.add(make_reservation(id="res_3", credit_type_id="ct_2", credits_reserved=999)) + await reservations.add(make_reservation(id="res_4", company_id="co_2", credits_reserved=999)) + + assert await reservations.reserved_credits("co_1", "ct_1") == 350 + assert await reservations.reserved_credits("co_1", "ct_2") == 999 + assert await reservations.reserved_credits("co_unknown", "ct_1") == 0 + + + async def test_reserved_credits_drops_a_consumed_hold( + leases: InMemoryLeaseStore, reservations: InMemoryReservationStore, clock: VirtualClock + ) -> None: + await leases.try_reserve("co_1", "ct_1", 100) + await reservations.add(make_reservation(expires_at=clock() + 60)) + assert await reservations.reserved_credits("co_1", "ct_1") == 100 + await reservations.consume("res_1", 30) + assert await reservations.reserved_credits("co_1", "ct_1") == 0 + tests/leases/test_wasm_credit_gate.py: | + """The credit gate against the real WASM rules engine. + + Every other lease test scripts the engine, so the contract that matters most + goes unexercised: resolving the matched credit entitlement from the probe, + substituting the lease balance into the company's credit balances, and letting + the engine's credit_cost gate decide. A drift in the option envelope or in the + entity shape the SDK feeds the engine fails here rather than mis-gating in + production. + """ + + from __future__ import annotations + + import logging + from typing import Any, Dict, List, Optional + + import pytest + from lease_support import ScriptedWireClient, VirtualClock + + from schematic.client import CheckFlagOptions, CheckOptions, CheckResult, EventUsage + from schematic.datastream.rules_engine import RulesEngineClient + from schematic.leases import ( + CreditCheckDeps, + InMemoryLeaseStore, + InMemoryReservationStore, + LeaseConfig, + LeaseManager, + check_with_lease, + ) + from schematic.types import ( + RulesengineCompany, + RulesengineCondition, + RulesengineFeatureEntitlement, + RulesengineFlag, + RulesengineRule, + ) + + wasmtime = pytest.importorskip("wasmtime", reason="wasmtime not installed") + + FLAG_KEY = "infer" + CREDIT_ID = "credit-1" + EVENT_SUBTYPE = "inference_tokens" + COMPANY_ID = "co" + LEASE_SIZE = 10_000.0 + + + def _credit_condition() -> RulesengineCondition: + # A consumption rate of 1 keeps credits equal to quantity, so the + # arithmetic in the assertions is the engine's own. + return RulesengineCondition( + id="cond-credit", + account_id="acct", + environment_id="env", + condition_type="credit", + operator="lt", + resource_ids=[], + trait_value="", + metric_value=0, + credit_id=CREDIT_ID, + consumption_rate=1, + event_subtype=EVENT_SUBTYPE, + ) + + + def _company_condition(resource_ids: List[str]) -> RulesengineCondition: + """A membership condition to flip, so the engine can deny for a reason that + has nothing to do with the balance.""" + return RulesengineCondition( + id="cond-company", + account_id="acct", + environment_id="env", + condition_type="company", + operator="eq", + resource_ids=resource_ids, + trait_value="", + metric_value=0, + ) + + + def _credit_flag(extra_conditions: Optional[List[RulesengineCondition]] = None) -> RulesengineFlag: + return RulesengineFlag( + id="flag-infer", + account_id="acct", + environment_id="env", + key=FLAG_KEY, + default_value=False, + rules=[ + RulesengineRule( + id="rule-credit", + account_id="acct", + environment_id="env", + name="Credit", + rule_type="plan_entitlement", + priority=100, + value=True, + conditions=[_credit_condition(), *(extra_conditions or [])], + condition_groups=[], + ) + ], + ) + + + def _company( + credit_balance: float, entitlements: Optional[List[RulesengineFeatureEntitlement]] = None + ) -> RulesengineCompany: + # The company carries its resolved entitlement for the feature, the shape + # the DataStream cache holds after a plan assignment. Entitlement-first + # resolution reads the credit, the rate, and the subtype off it. + default = RulesengineFeatureEntitlement( + feature_id="feat-infer", + feature_key=FLAG_KEY, + value_type="credit", + credit_id=CREDIT_ID, + consumption_rate=1, + event_subtype=EVENT_SUBTYPE, + credit_total=credit_balance, + credit_used=0, + credit_remaining=credit_balance, + ) + return RulesengineCompany( + id=COMPANY_ID, + account_id="acct", + environment_id="env", + keys={"id": COMPANY_ID}, + traits=[], + metrics=[], + rules=[], + entitlements=entitlements if entitlements is not None else [default], + billing_product_ids=[], + credit_balances={CREDIT_ID: credit_balance}, + plan_ids=[], + plan_version_ids=[], + ) + + + class RealEngineDataStream: + """Serves a fixed flag and company, and the real rules engine behind them.""" + + def __init__(self, engine: RulesEngineClient, flag: RulesengineFlag, company: RulesengineCompany) -> None: + self._engine = engine + self._flag = flag + self._company = company + + async def get_flag(self, flag_key: str) -> RulesengineFlag: + return self._flag + + async def get_company(self, keys: Dict[str, str]) -> RulesengineCompany: + return self._company + + async def get_user(self, keys: Dict[str, str]) -> None: + return None + + def evaluate_flag(self, flag: Any, company: Any, user: Any, options: Any = None) -> Any: + return self._engine.check_flag(flag, company, user, options) + + + def _deps( + engine: RulesEngineClient, flag: RulesengineFlag, company: RulesengineCompany, clock: VirtualClock + ) -> CreditCheckDeps: + leases = InMemoryLeaseStore(clock=clock) + reservations = InMemoryReservationStore(leases, clock=clock) + wire = ScriptedWireClient() + wire.acquire_responses.append( + {"lease": {"lease_id": "lse-1", "granted_amount": LEASE_SIZE, "expires_at": clock() + 60}} + ) + manager = LeaseManager( + wire, + leases, + reservation_store=reservations, + config=LeaseConfig(lease_duration=60.0, reservation_ttl=60.0, lease_size=LEASE_SIZE), + clock=clock, + ) + async def enqueue_flag_check(body: Any) -> None: + """These tests pin the engine's verdict, not the analytics event.""" + + return CreditCheckDeps( + datastream=RealEngineDataStream(engine, flag, company), + lease_store=leases, + reservations=reservations, + manager=manager, + logger=logging.getLogger("wasm-credit-gate-test"), + enqueue_flag_check=enqueue_flag_check, + clock=clock, + ) + + + async def _fail_fallback() -> CheckResult: + raise AssertionError("the lease path should not have fallen back") + + + @pytest.fixture + async def engine() -> RulesEngineClient: + client = RulesEngineClient() + await client.initialize() + return client + + + class TestCreditGateAgainstTheRealEngine: + async def test_a_within_balance_usage_passes_and_holds( + self, engine: RulesEngineClient, clock: VirtualClock + ) -> None: + deps = _deps(engine, _credit_flag(), _company(100), clock) + result = await check_with_lease( + deps, + FLAG_KEY, + {"id": COMPANY_ID}, + None, + CheckOptions(usage=50, event_subtype=EVENT_SUBTYPE), + _fail_fallback, + ) + await deps.manager._drain_background() + + assert result.allowed is True + assert result.value is True + assert result.reservation is not None + assert result.reservation.credit_type_id == CREDIT_ID + assert result.reservation.credits_reserved == 50 + entry = await deps.lease_store.get(COMPANY_ID, CREDIT_ID) + assert entry is not None and entry.local_remaining_credits == LEASE_SIZE - 50 + assert await deps.reservations.count() == 1 + + async def test_a_non_credit_denial_refunds_the_hold( + self, engine: RulesEngineClient, clock: VirtualClock + ) -> None: + # Credits are plentiful, but the membership condition excludes this + # company, so the hold taken before the gate has to come back. + flag = _credit_flag([_company_condition(["some-other-company"])]) + deps = _deps(engine, flag, _company(10_000), clock) + result = await check_with_lease( + deps, + FLAG_KEY, + {"id": COMPANY_ID}, + None, + CheckOptions(usage=50, event_subtype=EVENT_SUBTYPE), + _fail_fallback, + ) + + assert result.allowed is False + assert result.value is False + assert result.reservation is None + entry = await deps.lease_store.get(COMPANY_ID, CREDIT_ID) + assert entry is not None and entry.local_remaining_credits == LEASE_SIZE + assert await deps.reservations.count() == 0 + + async def test_an_override_granted_company_never_touches_the_lease( + self, engine: RulesEngineClient, clock: VirtualClock + ) -> None: + # A company override grants the feature outright, so the company's + # effective entitlement is boolean rather than credit-metered: no + # reserve-then-cancel, and no credits billed for usage the override + # grants for free. + flag = _credit_flag() + override = RulesengineRule( + id="rule-override", + account_id="acct", + environment_id="env", + name="Override", + rule_type="company_override", + priority=1, + value=True, + conditions=[_company_condition([COMPANY_ID])], + condition_groups=[], + ) + flag = flag.model_copy(update={"rules": [override, *flag.rules]}) + company = _company( + 100, + [RulesengineFeatureEntitlement(feature_id="feat-infer", feature_key=FLAG_KEY, value_type="boolean")], + ) + deps = _deps(engine, flag, company, clock) + fell_back = False + + async def fallback() -> CheckResult: + nonlocal fell_back + fell_back = True + return CheckResult(allowed=True, value=True, reason="override", flag_key=FLAG_KEY) + + result = await check_with_lease( + deps, FLAG_KEY, {"id": COMPANY_ID}, None, CheckOptions(usage=50, event_subtype=EVENT_SUBTYPE), fallback + ) + + assert fell_back is True + assert result.allowed is True + assert result.reservation is None + assert await deps.lease_store.get(COMPANY_ID, CREDIT_ID) is None + assert await deps.reservations.count() == 0 + + async def test_the_preflight_envelope_gates_at_the_balance_boundary(self, engine: RulesEngineClient) -> None: + # The contract the lease path leans on: the SDK's event_usage option + # reaches the engine as the envelope it gates on. + flag = _credit_flag() + company = _company(100) + + under = engine.check_flag( + flag, company, None, CheckFlagOptions(event_usage=EventUsage(event_subtype=EVENT_SUBTYPE, quantity=50)) + ) + over = engine.check_flag( + flag, company, None, CheckFlagOptions(event_usage=EventUsage(event_subtype=EVENT_SUBTYPE, quantity=150)) + ) + + assert under.value is True + assert over.value is False + tests/leases/test_wire_client.py: | + """The adapter between the manager and the generated credits client.""" + + from __future__ import annotations + + import datetime as dt + from typing import Any, Dict, List, Optional + + from schematic.credits.types.acquire_credit_lease_response import AcquireCreditLeaseResponse + from schematic.credits.types.extend_credit_lease_response import ExtendCreditLeaseResponse + from schematic.leases import CreditsWireClient, LeaseWireClient + from schematic.types.credit_lease_response_data import CreditLeaseResponseData + + EXPIRES_AT = dt.datetime(2026, 1, 1, 0, 5, tzinfo=dt.timezone.utc) + + + def _lease_data(lease_id: str = "lse_1", granted_amount: float = 1000) -> CreditLeaseResponseData: + now = dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc) + return CreditLeaseResponseData( + id=lease_id, + company_id="co_1", + credit_type_id="ct_1", + granted_amount=granted_amount, + tracked_amount=0, + expires_at=EXPIRES_AT, + created_at=now, + updated_at=now, + ) + + + class StubCreditsClient: + def __init__(self) -> None: + self.acquire_calls: List[Dict[str, Any]] = [] + self.extend_calls: List[Dict[str, Any]] = [] + self.release_calls: List[str] = [] + + async def acquire_credit_lease(self, **kwargs: Any) -> AcquireCreditLeaseResponse: + self.acquire_calls.append(kwargs) + return AcquireCreditLeaseResponse(data=_lease_data(), params={}) + + async def extend_credit_lease(self, lease_id: str, **kwargs: Any) -> ExtendCreditLeaseResponse: + self.extend_calls.append({"lease_id": lease_id, **kwargs}) + return ExtendCreditLeaseResponse(data=_lease_data(granted_amount=2000), params={}) + + async def release_credit_lease(self, lease_id: str, **kwargs: Any) -> None: + self.release_calls.append(lease_id) + + + async def test_acquire_maps_the_request_and_the_response() -> None: + stub = StubCreditsClient() + wire: LeaseWireClient = CreditsWireClient(stub) + + grant = await wire.acquire("co_1", "ct_1", 1000, EXPIRES_AT.timestamp()) + + assert stub.acquire_calls[0]["company_id"] == "co_1" + assert stub.acquire_calls[0]["credit_type_id"] == "ct_1" + assert stub.acquire_calls[0]["requested_amount"] == 1000 + assert stub.acquire_calls[0]["expires_at"] == EXPIRES_AT + assert grant.lease_id == "lse_1" + assert grant.granted_amount == 1000 + assert grant.expires_at == EXPIRES_AT.timestamp() + + + async def test_extend_sends_the_additional_amount_and_reads_back_the_total() -> None: + stub = StubCreditsClient() + wire: LeaseWireClient = CreditsWireClient(stub) + + grant = await wire.extend("lse_1", 500, EXPIRES_AT.timestamp()) + + assert stub.extend_calls[0]["lease_id"] == "lse_1" + assert stub.extend_calls[0]["additional_amount"] == 500 + # The response carries the server-authoritative TOTAL, not the increment. + assert grant.granted_amount == 2000 + + + async def test_release_passes_the_lease_id() -> None: + stub = StubCreditsClient() + wire: LeaseWireClient = CreditsWireClient(stub) + await wire.release("lse_1") + assert stub.release_calls == ["lse_1"] + + + async def test_a_naive_expiry_is_read_as_utc() -> None: + # A naive timestamp from the API is UTC; reading it as local time would + # shift every expiry by the pod's offset. + class NaiveClient(StubCreditsClient): + async def acquire_credit_lease(self, **kwargs: Any) -> AcquireCreditLeaseResponse: + data = _lease_data().model_copy(update={"expires_at": EXPIRES_AT.replace(tzinfo=None)}) + return AcquireCreditLeaseResponse(data=data, params={}) + + wire: LeaseWireClient = CreditsWireClient(NaiveClient()) + grant = await wire.acquire("co_1", "ct_1", 1000, EXPIRES_AT.timestamp()) + assert grant.expires_at == EXPIRES_AT.timestamp() + + + async def test_request_options_are_threaded_through() -> None: + stub = StubCreditsClient() + options: Optional[Any] = {"timeout_in_seconds": 2} + wire: LeaseWireClient = CreditsWireClient(stub, request_options=options) + await wire.acquire("co_1", "ct_1", 1000, EXPIRES_AT.timestamp()) + assert stub.acquire_calls[0]["request_options"] == options + + + async def test_a_per_call_timeout_becomes_request_options() -> None: + stub = StubCreditsClient() + wire: LeaseWireClient = CreditsWireClient(stub) + + await wire.acquire("co_1", "ct_1", 1000, EXPIRES_AT.timestamp(), 1.5) + await wire.extend("lse_1", 500, EXPIRES_AT.timestamp(), 1.5) + + assert stub.acquire_calls[0]["request_options"] == {"timeout": 1.5} + assert stub.extend_calls[0]["request_options"] == {"timeout": 1.5} + + + async def test_no_timeout_sends_no_request_options() -> None: + stub = StubCreditsClient() + wire: LeaseWireClient = CreditsWireClient(stub) + + await wire.acquire("co_1", "ct_1", 1000, EXPIRES_AT.timestamp()) + await wire.extend("lse_1", 500, EXPIRES_AT.timestamp()) + + assert stub.acquire_calls[0]["request_options"] is None + assert stub.extend_calls[0]["request_options"] is None + + + async def test_a_per_call_timeout_wins_over_the_client_wide_options() -> None: + stub = StubCreditsClient() + wire: LeaseWireClient = CreditsWireClient(stub, request_options={"timeout": 30}) + + await wire.acquire("co_1", "ct_1", 1000, EXPIRES_AT.timestamp(), 1.5) + + assert stub.acquire_calls[0]["request_options"] == {"timeout": 1.5} + status: unresolved diff --git a/.fernignore b/.fernignore index da5ab92..ce838ad 100644 --- a/.fernignore +++ b/.fernignore @@ -18,10 +18,15 @@ src/schematic/event_capture.py src/schematic/http_client.py src/schematic/logging.py src/schematic/datastream/ +src/schematic/leases/ src/schematic/webhook_utils/ src/schematic/webhooks/verification.py tests/custom/ tests/datastream/ +tests/leases/ +tests/conformance/ +tests/lease_support.py +conformance/ tests/webhook_utils/ CLAUDE.md WASM_VERSION diff --git a/pyproject.toml b/pyproject.toml index 21cebff..6ceaf29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ dynamic = ["version"] [tool.poetry] name = "schematichq" -version = "1.4.0" +version = "1.4.1" description = "" readme = "README.md" authors = [] diff --git a/reference.md b/reference.md index 34ca7e6..b0bc7f9 100644 --- a/reference.md +++ b/reference.md @@ -17333,6 +17333,8 @@ client.plans.list_plans( q="q", scoped_to_company_id="scoped_to_company_id", with_entitlements=True, + with_published_version=True, + without_entitlement_for_include_drafts=True, without_entitlement_for="without_entitlement_for", without_paid_product_id=True, limit=1000000, @@ -17465,6 +17467,22 @@ client.plans.list_plans(
+**with_published_version:** `typing.Optional[bool]` — Only return plans that have a published version + +
+
+ +
+
+ +**without_entitlement_for_include_drafts:** `typing.Optional[bool]` — With without_entitlement_for, also treat an entitlement on a plan's draft version as existing + +
+
+ +
+
+ **without_entitlement_for:** `typing.Optional[str]` — Filter out plans that already have a plan entitlement for the specified feature ID
@@ -18153,6 +18171,8 @@ client.plans.count_plans( q="q", scoped_to_company_id="scoped_to_company_id", with_entitlements=True, + with_published_version=True, + without_entitlement_for_include_drafts=True, without_entitlement_for="without_entitlement_for", without_paid_product_id=True, limit=1000000, @@ -18285,6 +18305,22 @@ client.plans.count_plans(
+**with_published_version:** `typing.Optional[bool]` — Only return plans that have a published version + +
+
+ +
+
+ +**without_entitlement_for_include_drafts:** `typing.Optional[bool]` — With without_entitlement_for, also treat an entitlement on a plan's draft version as existing + +
+
+ +
+
+ **without_entitlement_for:** `typing.Optional[str]` — Filter out plans that already have a plan entitlement for the specified feature ID
@@ -24803,6 +24839,7 @@ client = Schematic( ) client.planmigrations.list_migrations( + feature_id="feature_id", plan_version_id="plan_version_id", status="cancelled", limit=1000000, @@ -24823,7 +24860,15 @@ client.planmigrations.list_migrations(
-**plan_version_id:** `str` +**feature_id:** `typing.Optional[str]` + +
+
+ +
+
+ +**plan_version_id:** `typing.Optional[str]`
@@ -25262,6 +25307,7 @@ client = Schematic( ) client.planmigrations.count_migrations( + feature_id="feature_id", plan_version_id="plan_version_id", status="cancelled", limit=1000000, @@ -25282,7 +25328,15 @@ client.planmigrations.count_migrations(
-**plan_version_id:** `str` +**feature_id:** `typing.Optional[str]` + +
+
+ +
+
+ +**plan_version_id:** `typing.Optional[str]`
diff --git a/src/schematic/core/client_wrapper.py b/src/schematic/core/client_wrapper.py index 4ac829c..85dc244 100644 --- a/src/schematic/core/client_wrapper.py +++ b/src/schematic/core/client_wrapper.py @@ -33,12 +33,12 @@ def get_headers(self) -> typing.Dict[str, str]: import platform headers: typing.Dict[str, str] = { - "User-Agent": "schematichq/1.4.0", + "User-Agent": "schematichq/1.4.1", "X-Fern-Language": "Python", "X-Fern-Runtime": f"python/{platform.python_version()}", "X-Fern-Platform": f"{platform.system().lower()}/{platform.release()}", "X-Fern-SDK-Name": "schematichq", - "X-Fern-SDK-Version": "1.4.0", + "X-Fern-SDK-Version": "1.4.1", **(self.get_custom_headers() or {}), } headers["X-Schematic-Api-Key"] = self.api_key diff --git a/src/schematic/planmigrations/client.py b/src/schematic/planmigrations/client.py index b244b87..d7a7907 100644 --- a/src/schematic/planmigrations/client.py +++ b/src/schematic/planmigrations/client.py @@ -184,7 +184,8 @@ def count_company_migrations( def list_migrations( self, *, - plan_version_id: str, + feature_id: typing.Optional[str] = None, + plan_version_id: typing.Optional[str] = None, status: typing.Optional[PlanVersionMigrationStatus] = None, limit: typing.Optional[int] = None, offset: typing.Optional[int] = None, @@ -193,7 +194,9 @@ def list_migrations( """ Parameters ---------- - plan_version_id : str + feature_id : typing.Optional[str] + + plan_version_id : typing.Optional[str] status : typing.Optional[PlanVersionMigrationStatus] @@ -219,6 +222,7 @@ def list_migrations( api_key="YOUR_API_KEY", ) client.planmigrations.list_migrations( + feature_id="feature_id", plan_version_id="plan_version_id", status="cancelled", limit=1000000, @@ -226,7 +230,12 @@ def list_migrations( ) """ _response = self._raw_client.list_migrations( - plan_version_id=plan_version_id, status=status, limit=limit, offset=offset, request_options=request_options + feature_id=feature_id, + plan_version_id=plan_version_id, + status=status, + limit=limit, + offset=offset, + request_options=request_options, ) return _response.data @@ -441,7 +450,8 @@ def retry_migration( def count_migrations( self, *, - plan_version_id: str, + feature_id: typing.Optional[str] = None, + plan_version_id: typing.Optional[str] = None, status: typing.Optional[PlanVersionMigrationStatus] = None, limit: typing.Optional[int] = None, offset: typing.Optional[int] = None, @@ -450,7 +460,9 @@ def count_migrations( """ Parameters ---------- - plan_version_id : str + feature_id : typing.Optional[str] + + plan_version_id : typing.Optional[str] status : typing.Optional[PlanVersionMigrationStatus] @@ -476,6 +488,7 @@ def count_migrations( api_key="YOUR_API_KEY", ) client.planmigrations.count_migrations( + feature_id="feature_id", plan_version_id="plan_version_id", status="cancelled", limit=1000000, @@ -483,7 +496,12 @@ def count_migrations( ) """ _response = self._raw_client.count_migrations( - plan_version_id=plan_version_id, status=status, limit=limit, offset=offset, request_options=request_options + feature_id=feature_id, + plan_version_id=plan_version_id, + status=status, + limit=limit, + offset=offset, + request_options=request_options, ) return _response.data @@ -723,7 +741,8 @@ async def main() -> None: async def list_migrations( self, *, - plan_version_id: str, + feature_id: typing.Optional[str] = None, + plan_version_id: typing.Optional[str] = None, status: typing.Optional[PlanVersionMigrationStatus] = None, limit: typing.Optional[int] = None, offset: typing.Optional[int] = None, @@ -732,7 +751,9 @@ async def list_migrations( """ Parameters ---------- - plan_version_id : str + feature_id : typing.Optional[str] + + plan_version_id : typing.Optional[str] status : typing.Optional[PlanVersionMigrationStatus] @@ -763,6 +784,7 @@ async def list_migrations( async def main() -> None: await client.planmigrations.list_migrations( + feature_id="feature_id", plan_version_id="plan_version_id", status="cancelled", limit=1000000, @@ -773,7 +795,12 @@ async def main() -> None: asyncio.run(main()) """ _response = await self._raw_client.list_migrations( - plan_version_id=plan_version_id, status=status, limit=limit, offset=offset, request_options=request_options + feature_id=feature_id, + plan_version_id=plan_version_id, + status=status, + limit=limit, + offset=offset, + request_options=request_options, ) return _response.data @@ -1028,7 +1055,8 @@ async def main() -> None: async def count_migrations( self, *, - plan_version_id: str, + feature_id: typing.Optional[str] = None, + plan_version_id: typing.Optional[str] = None, status: typing.Optional[PlanVersionMigrationStatus] = None, limit: typing.Optional[int] = None, offset: typing.Optional[int] = None, @@ -1037,7 +1065,9 @@ async def count_migrations( """ Parameters ---------- - plan_version_id : str + feature_id : typing.Optional[str] + + plan_version_id : typing.Optional[str] status : typing.Optional[PlanVersionMigrationStatus] @@ -1068,6 +1098,7 @@ async def count_migrations( async def main() -> None: await client.planmigrations.count_migrations( + feature_id="feature_id", plan_version_id="plan_version_id", status="cancelled", limit=1000000, @@ -1078,7 +1109,12 @@ async def main() -> None: asyncio.run(main()) """ _response = await self._raw_client.count_migrations( - plan_version_id=plan_version_id, status=status, limit=limit, offset=offset, request_options=request_options + feature_id=feature_id, + plan_version_id=plan_version_id, + status=status, + limit=limit, + offset=offset, + request_options=request_options, ) return _response.data diff --git a/src/schematic/planmigrations/raw_client.py b/src/schematic/planmigrations/raw_client.py index 0765fe0..6e5571e 100644 --- a/src/schematic/planmigrations/raw_client.py +++ b/src/schematic/planmigrations/raw_client.py @@ -392,7 +392,8 @@ def count_company_migrations( def list_migrations( self, *, - plan_version_id: str, + feature_id: typing.Optional[str] = None, + plan_version_id: typing.Optional[str] = None, status: typing.Optional[PlanVersionMigrationStatus] = None, limit: typing.Optional[int] = None, offset: typing.Optional[int] = None, @@ -401,7 +402,9 @@ def list_migrations( """ Parameters ---------- - plan_version_id : str + feature_id : typing.Optional[str] + + plan_version_id : typing.Optional[str] status : typing.Optional[PlanVersionMigrationStatus] @@ -423,6 +426,7 @@ def list_migrations( "plan-version-migrations", method="GET", params={ + "feature_id": feature_id, "plan_version_id": plan_version_id, "status": status, "limit": limit, @@ -1063,7 +1067,8 @@ def retry_migration( def count_migrations( self, *, - plan_version_id: str, + feature_id: typing.Optional[str] = None, + plan_version_id: typing.Optional[str] = None, status: typing.Optional[PlanVersionMigrationStatus] = None, limit: typing.Optional[int] = None, offset: typing.Optional[int] = None, @@ -1072,7 +1077,9 @@ def count_migrations( """ Parameters ---------- - plan_version_id : str + feature_id : typing.Optional[str] + + plan_version_id : typing.Optional[str] status : typing.Optional[PlanVersionMigrationStatus] @@ -1094,6 +1101,7 @@ def count_migrations( "plan-version-migrations/count", method="GET", params={ + "feature_id": feature_id, "plan_version_id": plan_version_id, "status": status, "limit": limit, @@ -1658,7 +1666,8 @@ async def count_company_migrations( async def list_migrations( self, *, - plan_version_id: str, + feature_id: typing.Optional[str] = None, + plan_version_id: typing.Optional[str] = None, status: typing.Optional[PlanVersionMigrationStatus] = None, limit: typing.Optional[int] = None, offset: typing.Optional[int] = None, @@ -1667,7 +1676,9 @@ async def list_migrations( """ Parameters ---------- - plan_version_id : str + feature_id : typing.Optional[str] + + plan_version_id : typing.Optional[str] status : typing.Optional[PlanVersionMigrationStatus] @@ -1689,6 +1700,7 @@ async def list_migrations( "plan-version-migrations", method="GET", params={ + "feature_id": feature_id, "plan_version_id": plan_version_id, "status": status, "limit": limit, @@ -2329,7 +2341,8 @@ async def retry_migration( async def count_migrations( self, *, - plan_version_id: str, + feature_id: typing.Optional[str] = None, + plan_version_id: typing.Optional[str] = None, status: typing.Optional[PlanVersionMigrationStatus] = None, limit: typing.Optional[int] = None, offset: typing.Optional[int] = None, @@ -2338,7 +2351,9 @@ async def count_migrations( """ Parameters ---------- - plan_version_id : str + feature_id : typing.Optional[str] + + plan_version_id : typing.Optional[str] status : typing.Optional[PlanVersionMigrationStatus] @@ -2360,6 +2375,7 @@ async def count_migrations( "plan-version-migrations/count", method="GET", params={ + "feature_id": feature_id, "plan_version_id": plan_version_id, "status": status, "limit": limit, diff --git a/src/schematic/planmigrations/types/count_migrations_params.py b/src/schematic/planmigrations/types/count_migrations_params.py index 26dd47e..6764a1b 100644 --- a/src/schematic/planmigrations/types/count_migrations_params.py +++ b/src/schematic/planmigrations/types/count_migrations_params.py @@ -12,6 +12,7 @@ class CountMigrationsParams(UniversalBaseModel): Input parameters """ + feature_id: typing.Optional[str] = None limit: typing.Optional[int] = pydantic.Field(default=None) """ Page limit (default 100) diff --git a/src/schematic/planmigrations/types/list_migrations_params.py b/src/schematic/planmigrations/types/list_migrations_params.py index 122ff98..9dfb27c 100644 --- a/src/schematic/planmigrations/types/list_migrations_params.py +++ b/src/schematic/planmigrations/types/list_migrations_params.py @@ -12,6 +12,7 @@ class ListMigrationsParams(UniversalBaseModel): Input parameters """ + feature_id: typing.Optional[str] = None limit: typing.Optional[int] = pydantic.Field(default=None) """ Page limit (default 100) diff --git a/src/schematic/plans/client.py b/src/schematic/plans/client.py index 7b89478..9be7dbe 100644 --- a/src/schematic/plans/client.py +++ b/src/schematic/plans/client.py @@ -356,6 +356,8 @@ def list_plans( q: typing.Optional[str] = None, scoped_to_company_id: typing.Optional[str] = None, with_entitlements: typing.Optional[bool] = None, + with_published_version: typing.Optional[bool] = None, + without_entitlement_for_include_drafts: typing.Optional[bool] = None, without_entitlement_for: typing.Optional[str] = None, without_paid_product_id: typing.Optional[bool] = None, limit: typing.Optional[int] = None, @@ -404,6 +406,12 @@ def list_plans( with_entitlements : typing.Optional[bool] Include each plan's entitlements in the response + with_published_version : typing.Optional[bool] + Only return plans that have a published version + + without_entitlement_for_include_drafts : typing.Optional[bool] + With without_entitlement_for, also treat an entitlement on a plan's draft version as existing + without_entitlement_for : typing.Optional[str] Filter out plans that already have a plan entitlement for the specified feature ID @@ -446,6 +454,8 @@ def list_plans( q="q", scoped_to_company_id="scoped_to_company_id", with_entitlements=True, + with_published_version=True, + without_entitlement_for_include_drafts=True, without_entitlement_for="without_entitlement_for", without_paid_product_id=True, limit=1000000, @@ -467,6 +477,8 @@ def list_plans( q=q, scoped_to_company_id=scoped_to_company_id, with_entitlements=with_entitlements, + with_published_version=with_published_version, + without_entitlement_for_include_drafts=without_entitlement_for_include_drafts, without_entitlement_for=without_entitlement_for, without_paid_product_id=without_paid_product_id, limit=limit, @@ -917,6 +929,8 @@ def count_plans( q: typing.Optional[str] = None, scoped_to_company_id: typing.Optional[str] = None, with_entitlements: typing.Optional[bool] = None, + with_published_version: typing.Optional[bool] = None, + without_entitlement_for_include_drafts: typing.Optional[bool] = None, without_entitlement_for: typing.Optional[str] = None, without_paid_product_id: typing.Optional[bool] = None, limit: typing.Optional[int] = None, @@ -965,6 +979,12 @@ def count_plans( with_entitlements : typing.Optional[bool] Include each plan's entitlements in the response + with_published_version : typing.Optional[bool] + Only return plans that have a published version + + without_entitlement_for_include_drafts : typing.Optional[bool] + With without_entitlement_for, also treat an entitlement on a plan's draft version as existing + without_entitlement_for : typing.Optional[str] Filter out plans that already have a plan entitlement for the specified feature ID @@ -1007,6 +1027,8 @@ def count_plans( q="q", scoped_to_company_id="scoped_to_company_id", with_entitlements=True, + with_published_version=True, + without_entitlement_for_include_drafts=True, without_entitlement_for="without_entitlement_for", without_paid_product_id=True, limit=1000000, @@ -1028,6 +1050,8 @@ def count_plans( q=q, scoped_to_company_id=scoped_to_company_id, with_entitlements=with_entitlements, + with_published_version=with_published_version, + without_entitlement_for_include_drafts=without_entitlement_for_include_drafts, without_entitlement_for=without_entitlement_for, without_paid_product_id=without_paid_product_id, limit=limit, @@ -1571,6 +1595,8 @@ async def list_plans( q: typing.Optional[str] = None, scoped_to_company_id: typing.Optional[str] = None, with_entitlements: typing.Optional[bool] = None, + with_published_version: typing.Optional[bool] = None, + without_entitlement_for_include_drafts: typing.Optional[bool] = None, without_entitlement_for: typing.Optional[str] = None, without_paid_product_id: typing.Optional[bool] = None, limit: typing.Optional[int] = None, @@ -1619,6 +1645,12 @@ async def list_plans( with_entitlements : typing.Optional[bool] Include each plan's entitlements in the response + with_published_version : typing.Optional[bool] + Only return plans that have a published version + + without_entitlement_for_include_drafts : typing.Optional[bool] + With without_entitlement_for, also treat an entitlement on a plan's draft version as existing + without_entitlement_for : typing.Optional[str] Filter out plans that already have a plan entitlement for the specified feature ID @@ -1666,6 +1698,8 @@ async def main() -> None: q="q", scoped_to_company_id="scoped_to_company_id", with_entitlements=True, + with_published_version=True, + without_entitlement_for_include_drafts=True, without_entitlement_for="without_entitlement_for", without_paid_product_id=True, limit=1000000, @@ -1690,6 +1724,8 @@ async def main() -> None: q=q, scoped_to_company_id=scoped_to_company_id, with_entitlements=with_entitlements, + with_published_version=with_published_version, + without_entitlement_for_include_drafts=without_entitlement_for_include_drafts, without_entitlement_for=without_entitlement_for, without_paid_product_id=without_paid_product_id, limit=limit, @@ -2206,6 +2242,8 @@ async def count_plans( q: typing.Optional[str] = None, scoped_to_company_id: typing.Optional[str] = None, with_entitlements: typing.Optional[bool] = None, + with_published_version: typing.Optional[bool] = None, + without_entitlement_for_include_drafts: typing.Optional[bool] = None, without_entitlement_for: typing.Optional[str] = None, without_paid_product_id: typing.Optional[bool] = None, limit: typing.Optional[int] = None, @@ -2254,6 +2292,12 @@ async def count_plans( with_entitlements : typing.Optional[bool] Include each plan's entitlements in the response + with_published_version : typing.Optional[bool] + Only return plans that have a published version + + without_entitlement_for_include_drafts : typing.Optional[bool] + With without_entitlement_for, also treat an entitlement on a plan's draft version as existing + without_entitlement_for : typing.Optional[str] Filter out plans that already have a plan entitlement for the specified feature ID @@ -2301,6 +2345,8 @@ async def main() -> None: q="q", scoped_to_company_id="scoped_to_company_id", with_entitlements=True, + with_published_version=True, + without_entitlement_for_include_drafts=True, without_entitlement_for="without_entitlement_for", without_paid_product_id=True, limit=1000000, @@ -2325,6 +2371,8 @@ async def main() -> None: q=q, scoped_to_company_id=scoped_to_company_id, with_entitlements=with_entitlements, + with_published_version=with_published_version, + without_entitlement_for_include_drafts=without_entitlement_for_include_drafts, without_entitlement_for=without_entitlement_for, without_paid_product_id=without_paid_product_id, limit=limit, diff --git a/src/schematic/plans/raw_client.py b/src/schematic/plans/raw_client.py index d90e185..e0be5b1 100644 --- a/src/schematic/plans/raw_client.py +++ b/src/schematic/plans/raw_client.py @@ -709,6 +709,8 @@ def list_plans( q: typing.Optional[str] = None, scoped_to_company_id: typing.Optional[str] = None, with_entitlements: typing.Optional[bool] = None, + with_published_version: typing.Optional[bool] = None, + without_entitlement_for_include_drafts: typing.Optional[bool] = None, without_entitlement_for: typing.Optional[str] = None, without_paid_product_id: typing.Optional[bool] = None, limit: typing.Optional[int] = None, @@ -757,6 +759,12 @@ def list_plans( with_entitlements : typing.Optional[bool] Include each plan's entitlements in the response + with_published_version : typing.Optional[bool] + Only return plans that have a published version + + without_entitlement_for_include_drafts : typing.Optional[bool] + With without_entitlement_for, also treat an entitlement on a plan's draft version as existing + without_entitlement_for : typing.Optional[str] Filter out plans that already have a plan entitlement for the specified feature ID @@ -795,6 +803,8 @@ def list_plans( "q": q, "scoped_to_company_id": scoped_to_company_id, "with_entitlements": with_entitlements, + "with_published_version": with_published_version, + "without_entitlement_for_include_drafts": without_entitlement_for_include_drafts, "without_entitlement_for": without_entitlement_for, "without_paid_product_id": without_paid_product_id, "limit": limit, @@ -1884,6 +1894,8 @@ def count_plans( q: typing.Optional[str] = None, scoped_to_company_id: typing.Optional[str] = None, with_entitlements: typing.Optional[bool] = None, + with_published_version: typing.Optional[bool] = None, + without_entitlement_for_include_drafts: typing.Optional[bool] = None, without_entitlement_for: typing.Optional[str] = None, without_paid_product_id: typing.Optional[bool] = None, limit: typing.Optional[int] = None, @@ -1932,6 +1944,12 @@ def count_plans( with_entitlements : typing.Optional[bool] Include each plan's entitlements in the response + with_published_version : typing.Optional[bool] + Only return plans that have a published version + + without_entitlement_for_include_drafts : typing.Optional[bool] + With without_entitlement_for, also treat an entitlement on a plan's draft version as existing + without_entitlement_for : typing.Optional[str] Filter out plans that already have a plan entitlement for the specified feature ID @@ -1970,6 +1988,8 @@ def count_plans( "q": q, "scoped_to_company_id": scoped_to_company_id, "with_entitlements": with_entitlements, + "with_published_version": with_published_version, + "without_entitlement_for_include_drafts": without_entitlement_for_include_drafts, "without_entitlement_for": without_entitlement_for, "without_paid_product_id": without_paid_product_id, "limit": limit, @@ -3105,6 +3125,8 @@ async def list_plans( q: typing.Optional[str] = None, scoped_to_company_id: typing.Optional[str] = None, with_entitlements: typing.Optional[bool] = None, + with_published_version: typing.Optional[bool] = None, + without_entitlement_for_include_drafts: typing.Optional[bool] = None, without_entitlement_for: typing.Optional[str] = None, without_paid_product_id: typing.Optional[bool] = None, limit: typing.Optional[int] = None, @@ -3153,6 +3175,12 @@ async def list_plans( with_entitlements : typing.Optional[bool] Include each plan's entitlements in the response + with_published_version : typing.Optional[bool] + Only return plans that have a published version + + without_entitlement_for_include_drafts : typing.Optional[bool] + With without_entitlement_for, also treat an entitlement on a plan's draft version as existing + without_entitlement_for : typing.Optional[str] Filter out plans that already have a plan entitlement for the specified feature ID @@ -3191,6 +3219,8 @@ async def list_plans( "q": q, "scoped_to_company_id": scoped_to_company_id, "with_entitlements": with_entitlements, + "with_published_version": with_published_version, + "without_entitlement_for_include_drafts": without_entitlement_for_include_drafts, "without_entitlement_for": without_entitlement_for, "without_paid_product_id": without_paid_product_id, "limit": limit, @@ -4280,6 +4310,8 @@ async def count_plans( q: typing.Optional[str] = None, scoped_to_company_id: typing.Optional[str] = None, with_entitlements: typing.Optional[bool] = None, + with_published_version: typing.Optional[bool] = None, + without_entitlement_for_include_drafts: typing.Optional[bool] = None, without_entitlement_for: typing.Optional[str] = None, without_paid_product_id: typing.Optional[bool] = None, limit: typing.Optional[int] = None, @@ -4328,6 +4360,12 @@ async def count_plans( with_entitlements : typing.Optional[bool] Include each plan's entitlements in the response + with_published_version : typing.Optional[bool] + Only return plans that have a published version + + without_entitlement_for_include_drafts : typing.Optional[bool] + With without_entitlement_for, also treat an entitlement on a plan's draft version as existing + without_entitlement_for : typing.Optional[str] Filter out plans that already have a plan entitlement for the specified feature ID @@ -4366,6 +4404,8 @@ async def count_plans( "q": q, "scoped_to_company_id": scoped_to_company_id, "with_entitlements": with_entitlements, + "with_published_version": with_published_version, + "without_entitlement_for_include_drafts": without_entitlement_for_include_drafts, "without_entitlement_for": without_entitlement_for, "without_paid_product_id": without_paid_product_id, "limit": limit, diff --git a/src/schematic/plans/types/count_plans_params.py b/src/schematic/plans/types/count_plans_params.py index 7ab426f..5509701 100644 --- a/src/schematic/plans/types/count_plans_params.py +++ b/src/schematic/plans/types/count_plans_params.py @@ -80,11 +80,21 @@ class CountPlansParams(UniversalBaseModel): Include each plan's entitlements in the response """ + with_published_version: typing.Optional[bool] = pydantic.Field(default=None) + """ + Only return plans that have a published version + """ + without_entitlement_for: typing.Optional[str] = pydantic.Field(default=None) """ Filter out plans that already have a plan entitlement for the specified feature ID """ + without_entitlement_for_include_drafts: typing.Optional[bool] = pydantic.Field(default=None) + """ + With without_entitlement_for, also treat an entitlement on a plan's draft version as existing + """ + without_paid_product_id: typing.Optional[bool] = pydantic.Field(default=None) """ Filter out plans that have a paid billing product ID diff --git a/src/schematic/plans/types/list_plans_params.py b/src/schematic/plans/types/list_plans_params.py index bebf39c..e75cb2e 100644 --- a/src/schematic/plans/types/list_plans_params.py +++ b/src/schematic/plans/types/list_plans_params.py @@ -80,11 +80,21 @@ class ListPlansParams(UniversalBaseModel): Include each plan's entitlements in the response """ + with_published_version: typing.Optional[bool] = pydantic.Field(default=None) + """ + Only return plans that have a published version + """ + without_entitlement_for: typing.Optional[str] = pydantic.Field(default=None) """ Filter out plans that already have a plan entitlement for the specified feature ID """ + without_entitlement_for_include_drafts: typing.Optional[bool] = pydantic.Field(default=None) + """ + With without_entitlement_for, also treat an entitlement on a plan's draft version as existing + """ + without_paid_product_id: typing.Optional[bool] = pydantic.Field(default=None) """ Filter out plans that have a paid billing product ID diff --git a/src/schematic/types/plan_version_migration_response_data.py b/src/schematic/types/plan_version_migration_response_data.py index a78a65e..2855151 100644 --- a/src/schematic/types/plan_version_migration_response_data.py +++ b/src/schematic/types/plan_version_migration_response_data.py @@ -16,6 +16,8 @@ class PlanVersionMigrationResponseData(UniversalBaseModel): created_at: dt.datetime error: typing.Optional[str] = None failed_companies: int + feature_id: typing.Optional[str] = None + feature_plan_rollout_id: typing.Optional[str] = None id: str next_due_at: typing.Optional[dt.datetime] = None plan_id: str