From 9d7a26db65580a306658d581b13b6a789401b4db Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:09:14 +0000 Subject: [PATCH 1/5] [fern-generated] Update SDK Generated by Fern CLI Version: unknown Generators: - fernapi/fern-python-sdk: 5.30.2 --- .fern/metadata.json | 8 +- conformance/README.md | 10 - conformance/SPEC.md | 446 ------ conformance/vectors/check-flow.json | 527 ------- conformance/vectors/crash-windows.json | 271 ---- conformance/vectors/expiry.json | 198 --- conformance/vectors/lease-lifecycle.json | 447 ------ conformance/vectors/lease-manager.json | 417 ------ .../vectors/reservation-lifecycle.json | 353 ----- conformance/vectors/track-settle.json | 194 --- poetry.lock | 1262 ++++++++--------- reference.md | 58 +- src/schematic/core/client_wrapper.py | 4 +- src/schematic/leases/__init__.py | 73 - src/schematic/leases/check.py | 526 ------- src/schematic/leases/lease_manager.py | 452 ------ src/schematic/leases/lease_store.py | 270 ---- src/schematic/leases/redis_lease_store.py | 349 ----- .../leases/redis_reservation_store.py | 278 ---- src/schematic/leases/reservation_store.py | 125 -- src/schematic/leases/track.py | 82 -- src/schematic/leases/types.py | 162 --- src/schematic/planmigrations/client.py | 60 +- src/schematic/planmigrations/raw_client.py | 32 +- .../types/count_migrations_params.py | 1 + .../types/list_migrations_params.py | 1 + src/schematic/plans/client.py | 48 + src/schematic/plans/raw_client.py | 40 + .../plans/types/count_plans_params.py | 10 + .../plans/types/list_plans_params.py | 10 + .../plan_version_migration_response_data.py | 2 + tests/conformance/test_vectors.py | 459 ------ tests/lease_support.py | 366 ----- tests/leases/__init__.py | 0 tests/leases/conftest.py | 29 - tests/leases/test_check_and_track.py | 675 --------- tests/leases/test_crash_windows.py | 207 --- tests/leases/test_lease_manager.py | 404 ------ tests/leases/test_lease_store.py | 229 --- tests/leases/test_redis_lease_store.py | 298 ---- tests/leases/test_redis_reservation_store.py | 226 --- tests/leases/test_reservation_store.py | 133 -- tests/leases/test_wasm_credit_gate.py | 300 ---- tests/leases/test_wire_client.py | 131 -- 44 files changed, 818 insertions(+), 9355 deletions(-) delete mode 100644 conformance/README.md delete mode 100644 conformance/SPEC.md delete mode 100644 conformance/vectors/check-flow.json delete mode 100644 conformance/vectors/crash-windows.json delete mode 100644 conformance/vectors/expiry.json delete mode 100644 conformance/vectors/lease-lifecycle.json delete mode 100644 conformance/vectors/lease-manager.json delete mode 100644 conformance/vectors/reservation-lifecycle.json delete mode 100644 conformance/vectors/track-settle.json delete mode 100644 src/schematic/leases/__init__.py delete mode 100644 src/schematic/leases/check.py delete mode 100644 src/schematic/leases/lease_manager.py delete mode 100644 src/schematic/leases/lease_store.py delete mode 100644 src/schematic/leases/redis_lease_store.py delete mode 100644 src/schematic/leases/redis_reservation_store.py delete mode 100644 src/schematic/leases/reservation_store.py delete mode 100644 src/schematic/leases/track.py delete mode 100644 src/schematic/leases/types.py delete mode 100644 tests/conformance/test_vectors.py delete mode 100644 tests/lease_support.py delete mode 100644 tests/leases/__init__.py delete mode 100644 tests/leases/conftest.py delete mode 100644 tests/leases/test_check_and_track.py delete mode 100644 tests/leases/test_crash_windows.py delete mode 100644 tests/leases/test_lease_manager.py delete mode 100644 tests/leases/test_lease_store.py delete mode 100644 tests/leases/test_redis_lease_store.py delete mode 100644 tests/leases/test_redis_reservation_store.py delete mode 100644 tests/leases/test_reservation_store.py delete mode 100644 tests/leases/test_wasm_credit_gate.py delete mode 100644 tests/leases/test_wire_client.py diff --git a/.fern/metadata.json b/.fern/metadata.json index 54f2c100..1dacf976 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/conformance/README.md b/conformance/README.md deleted file mode 100644 index 0b0f4060..00000000 --- a/conformance/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# 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 deleted file mode 100644 index 6191f101..00000000 --- a/conformance/SPEC.md +++ /dev/null @@ -1,446 +0,0 @@ -# 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 deleted file mode 100644 index eff99903..00000000 --- a/conformance/vectors/check-flow.json +++ /dev/null @@ -1,527 +0,0 @@ -{ - "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 deleted file mode 100644 index c8ee05b9..00000000 --- a/conformance/vectors/crash-windows.json +++ /dev/null @@ -1,271 +0,0 @@ -{ - "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 deleted file mode 100644 index e1f7e2cc..00000000 --- a/conformance/vectors/expiry.json +++ /dev/null @@ -1,198 +0,0 @@ -{ - "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 deleted file mode 100644 index 085b5faa..00000000 --- a/conformance/vectors/lease-lifecycle.json +++ /dev/null @@ -1,447 +0,0 @@ -{ - "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 deleted file mode 100644 index 79ab1afa..00000000 --- a/conformance/vectors/lease-manager.json +++ /dev/null @@ -1,417 +0,0 @@ -{ - "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 deleted file mode 100644 index eea6b50e..00000000 --- a/conformance/vectors/reservation-lifecycle.json +++ /dev/null @@ -1,353 +0,0 @@ -{ - "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 deleted file mode 100644 index 1da71a09..00000000 --- a/conformance/vectors/track-settle.json +++ /dev/null @@ -1,194 +0,0 @@ -{ - "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 59e6adf8..35852ec3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,11 +1,13 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" version = "2.7.1" description = "Happy Eyeballs for asyncio" -optional = false +optional = true python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"aiohttp\"" files = [ {file = "aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472"}, {file = "aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d"}, @@ -15,8 +17,10 @@ files = [ name = "aiohttp" version = "3.14.3" description = "Async http client/server framework (asyncio)" -optional = false +optional = true python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"aiohttp\"" 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"}, @@ -151,14 +155,16 @@ 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)"] +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\""] [[package]] name = "aiosignal" version = "1.4.0" description = "aiosignal: a list of registered asynchronous callbacks" -optional = false +optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"aiohttp\"" files = [ {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, @@ -174,6 +180,7 @@ 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"}, @@ -181,13 +188,14 @@ files = [ [[package]] name = "anyio" -version = "4.15.0" +version = "4.15.1" 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"}, + {file = "anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101"}, + {file = "anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94"}, ] [package.dependencies] @@ -202,8 +210,10 @@ trio = ["trio (>=0.32.0)"] name = "async-timeout" version = "5.0.1" description = "Timeout context manager for asyncio programs" -optional = false +optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "python_version == \"3.10\" and extra == \"aiohttp\"" 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"}, @@ -213,19 +223,35 @@ files = [ name = "attrs" version = "26.1.0" description = "Classes Without Boilerplate" -optional = false +optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"aiohttp\"" 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 = "backports-asyncio-runner" +version = "1.2.0" +description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." +optional = false +python-versions = "<3.11,>=3.8" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, + {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, +] + [[package]] name = "certifi" 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"}, @@ -237,6 +263,8 @@ 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"}, @@ -248,6 +276,8 @@ 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"}, @@ -265,6 +295,7 @@ 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"}, @@ -273,38 +304,14 @@ 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 +optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"aiohttp\"" 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"}, @@ -444,6 +451,7 @@ 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"}, @@ -455,6 +463,7 @@ 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"}, @@ -476,6 +485,7 @@ 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"}, @@ -488,7 +498,7 @@ httpcore = "==1.*" idna = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -500,6 +510,8 @@ 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"}, @@ -515,6 +527,7 @@ version = "3.19" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4"}, {file = "idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15"}, @@ -529,240 +542,192 @@ 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" +version = "6.8.0" description = "multidict implementation" -optional = false -python-versions = ">=3.9" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"aiohttp\"" 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"}, + {file = "multidict-6.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c0fe437a6d2f36aac2b49517057776575b5bf359df314cca20d230a6e139c089"}, + {file = "multidict-6.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f3a0a31189acf6703307397c6139ddabd734c20c5ef92649fc93e473df6615a3"}, + {file = "multidict-6.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c9c98d2f0126ba84cb45601eed97ff67ff767e19ae6eb3c31b02827b54d700e5"}, + {file = "multidict-6.8.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bc7003991ebd368a20d05228137a37b3d3066751f3ea1e4f7b8efe8e752f2f5"}, + {file = "multidict-6.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2c5fd0fd39574ccd58e1a52565b341aff522c5c836f1b3eb7605c371e61f52c"}, + {file = "multidict-6.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1cd4d66ce894a45482e1ac2837c31d0bd447df35065e542b60055aa2d00404b"}, + {file = "multidict-6.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a2573d0fd34f361a4a14e54d8cda3a91ac4e55fbf0d719698024f3b09c5b147"}, + {file = "multidict-6.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edda19aff836ec515caafc09ea53d2ab144a041f09ee9a7cefcbd3ae4e976256"}, + {file = "multidict-6.8.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e418ec99574ca24365ca96546af285c2b021a1a072478a79f0e3cc3b08837154"}, + {file = "multidict-6.8.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11d71490bf4bbff1141b14b93af419ad68c56b60bea9277fcb3f94dcca4796eb"}, + {file = "multidict-6.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e8e1e895e23818d343e4ae7dd95a0a556fdeaf8b471acf1c0a39b93c6f54d478"}, + {file = "multidict-6.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:15db8e6cab5f4cc9241bc56e69fdf3452cf49c10ee3c7977c742e68a275b3786"}, + {file = "multidict-6.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8dc2d9c3a924ed14166e63650b2cf9f59e7821743bdd50b23802bd97ca09bde5"}, + {file = "multidict-6.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f2fa3d3b1c933d4bcb8fd2018700d5e7235c52f2ab8c88d22286965c5c0f00f8"}, + {file = "multidict-6.8.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d6dc7804c50fabd28644d4d18a4b20aad3681b3e64f3acd3182b330ca73f7a32"}, + {file = "multidict-6.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0eca15d627e942ce186a935061f1568cc46c02e97c419c8da802df2be9f917d8"}, + {file = "multidict-6.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d7e5ba0a0153e35fbce9c51df530c8b4cb0c3012b46a04ff9a048441a269c2ed"}, + {file = "multidict-6.8.0-cp310-cp310-win32.whl", hash = "sha256:b7e62b8fc7bd6cad007b9f2e0ad9c8d4854c06350d5f51e1a439dd18b510ecac"}, + {file = "multidict-6.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:dc911ae6152e455b16a2a1a626aa6cd612fa01efb9d0a4ab3f5cf328b911483d"}, + {file = "multidict-6.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:6300d5176647145ba1e22991c924fb29743e54b4d7b8bc85a0d3ec0e55e189cb"}, + {file = "multidict-6.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b8986d4313dcee7c932837d16a535f1840b827bac1ea7c5c4c80751d0423794"}, + {file = "multidict-6.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2622fe114c0bd66ca5c461859357587f5a5e35ee5ff49fc5643d1bc78dbb41c6"}, + {file = "multidict-6.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26a7aafc992e78872e2c8c1f7248c0e01139cf9020a7781b0c064fa566832712"}, + {file = "multidict-6.8.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:05c2e90c5289c5f7436ba2c25812a5fbdaa1c1bc11c8d8d3bbf64f5cd7c633dd"}, + {file = "multidict-6.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7941ef106ca1f2c62314a13c7ed913bcf49641f3efdc12864d588e17870920ac"}, + {file = "multidict-6.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a5a7ee1217949ddd43c6b7bcf70d5c22193bb50e8c695386de5905325e93ce9f"}, + {file = "multidict-6.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b62b7e0025aa48dec11e125e655d1157985a5fdcec04b1ad500101ad072b891"}, + {file = "multidict-6.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea880d441be7c510106bc56064be39266d948aef94ad4955e8784690019a5d9f"}, + {file = "multidict-6.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab83fdd8cf307353edba9c427c17a3a021c2522d690f5633dd9f72d28b48ccca"}, + {file = "multidict-6.8.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3d1f48582686a0a3b81e9b43234766cc96697df72081af3f48107bd3f34d34e5"}, + {file = "multidict-6.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:563661919f603374c40cf45ffcd25535c12b8954203569a2ab1cee5265871cf4"}, + {file = "multidict-6.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2ba9933e8f35fe4a70f540b837254c4055da82dc3a9e500a8f95e61498083a15"}, + {file = "multidict-6.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:68d40b2bace413f3231f5729d3fcfb1837fd31c4907e241b5d43211bfd76f3c2"}, + {file = "multidict-6.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a9e246f67ac038568b854ed7c5578e4c6af1f742359901a8fcc3603ff1358df6"}, + {file = "multidict-6.8.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:29b6e7bc4442a56cf8e0dc1cabf3fdc77cd533568d6829fc76a1effd2ce332ec"}, + {file = "multidict-6.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:82780eb8bf59e8fb25dd081fde6e058805045d6374a7f2f877effc826ca4434b"}, + {file = "multidict-6.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2196ba6df392c3574acadd14ef87550f3611349c8618564de324b806a7a31cee"}, + {file = "multidict-6.8.0-cp311-cp311-win32.whl", hash = "sha256:b8b7aa75146266fd3e2a2437cf69ae188688c04ab8665b163d4257b46c1e0c83"}, + {file = "multidict-6.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:b03ca066b47b18b205cc080dca6f76cbd159f8cdd33a02a0700164c13b37e463"}, + {file = "multidict-6.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:54af1266710cb0f305127ae0b970aff8d208057f8a29cd6e1db99b0114947035"}, + {file = "multidict-6.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:22a310ad37672a261e55a8b5e28d0ae08cfb68abb1f46418ccd19835c3b8e836"}, + {file = "multidict-6.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bb8c7da8c861391f7ae48e3593762be2dabe405109e01aec520fbe1a6d15d14b"}, + {file = "multidict-6.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10456943903744ae1249728161c96bd9d2f7eb5ee17fcc2ffda2dc32e1bb36c7"}, + {file = "multidict-6.8.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:658f5a1895b804423d97b22d06fc0d0b171c7c01dcc3aa9c8faf0c0e26a249a5"}, + {file = "multidict-6.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c10b22860dbd09982d0b8993b66231a861bea2993d4a817ff35273f6ea285a"}, + {file = "multidict-6.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:088b04a66b3c1fce6fe4d771ec184a0426262d0b86709c908477b4ac7965df40"}, + {file = "multidict-6.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9caef53b20a105c0d66518a34be2f71b2783de8d091767575ef86f6ea422236d"}, + {file = "multidict-6.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a5e1583c14775580da05641240ce0d93f36ce3ddef3d5083a827468b0bcfe874"}, + {file = "multidict-6.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:003a3bddb32915c3f67096ea41d24e53edf710edb65a1f5d0c70ab40b0e4d20b"}, + {file = "multidict-6.8.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35977263d9bf506dbc65349f63b3b8c91606d4abc110990945e3b94bc671319c"}, + {file = "multidict-6.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9dc7b4ff6ef184504b49ef9a4113d49a646653b2ce89f5f48c1f57cdf6ba081"}, + {file = "multidict-6.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:887f9a975996032c686719eb7b3e1e7942fab5079c2b778bbd9afe9a9d78244f"}, + {file = "multidict-6.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f3071e6515cc63714d014da8f738ae9fa3997c476203f3cd46de380c2376ed7b"}, + {file = "multidict-6.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5f3a2af441670d80ce5fdf13b6c1b421fc1fc7fc5182d58ac7486738bb2b742"}, + {file = "multidict-6.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:31e8901637e20ccb3cf8f8848b5d0f7a00462bf5b34f7cf3dcbb2753b18e8b39"}, + {file = "multidict-6.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:13967dca8b2f33230a1427b52438326bb1c9101a1df22a3309ed3fcbbb3c96f0"}, + {file = "multidict-6.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad474c11d851b6fc97cb625e4822bc0cbd567fc07dc2602e28faec5a36b42bbb"}, + {file = "multidict-6.8.0-cp312-cp312-win32.whl", hash = "sha256:7bb0dad75068fee80fcb60f88569722c199d8656a16706702dc6e3b786819c90"}, + {file = "multidict-6.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:7d26dc8f070c0ec5579e987fa615ffd6883086106eefdff9e10d160fc5630630"}, + {file = "multidict-6.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:e6ec7d37841609a691b96a10b4fde386c7cd93ebbb939f59c9f23325ee788395"}, + {file = "multidict-6.8.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:ec0a4d066356054d569a66e0a94691a2058b680be5e710298f61db11a3c4609f"}, + {file = "multidict-6.8.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:714597cb5d5e15a8a449d2ae23c45b486a9e8fa33c462c7a33d7f35b65d92943"}, + {file = "multidict-6.8.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e0db3a4d1e264e225037a6023888972c25206a96e016021a5bea41c9a939f2a9"}, + {file = "multidict-6.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:27747162712e85c84598d364425dbf1714ff335bdb6ba3171c4e5081196e8916"}, + {file = "multidict-6.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:847d6082ae694dc95e548acb201bc100e1cfa96513bc71fdcb86f709dad6c435"}, + {file = "multidict-6.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:88a6df88567680504ae28bfa7a1f2f64243d91e79a40b2c92ef42efc531e23da"}, + {file = "multidict-6.8.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:560b211fc3bd4a1e1c6de44f6d38113bf5b410dfc89a4c0d2a3c0edbf1a0dfb8"}, + {file = "multidict-6.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202436df907c15adbb94360296c425ea53cf8968a5d2cff9b5b9790ae1972b33"}, + {file = "multidict-6.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c46a08bf070d6849fed483e9d9833f9d06aecb8382ed985be0b38508b3ae958e"}, + {file = "multidict-6.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cd560498ae8e1bcc955643c1d78eb8e338226d07a983c656ea8c4443d3eec0f"}, + {file = "multidict-6.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:758233648ac47b07c575224c4eadd73c8929c3b4c31e2afcfea935fde1cda735"}, + {file = "multidict-6.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:122adc7c46ac1e31ecfc7f81b2530533dccafdba70f5d741649f87e336c63384"}, + {file = "multidict-6.8.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8125e60f3c70e323ac07dd8b3635f7b3bbc5c3a9ac04ae5988f668ff7ae28a18"}, + {file = "multidict-6.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:83ff054b04915be5c15680da6c6012474a2cc2bf534129a0e8c6a99f17ba7238"}, + {file = "multidict-6.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:930c6058047410e3edff445f5a6e4457f2e089042dede00e2d18ce06f3ceae2e"}, + {file = "multidict-6.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:13e26f59f0eecfc5f67c663ad550ffdaf62c0f657547cde387f6c86af1c9449e"}, + {file = "multidict-6.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fd789a294d8e098528be29b2669b83005ce569339f8cef167fc0274c3115c34c"}, + {file = "multidict-6.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:3126f2a96704505aa4e92a72d6e8a5d7f29d40a987ced8bf69e29d71dfc71fbc"}, + {file = "multidict-6.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:23c9ee89967b6a9b4048acb3b93b660ed714ce9c8bf3bbe652959bc120dc02dc"}, + {file = "multidict-6.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a62e302fc8cd6aa8972207e7e951d1fdee7c1dda18568305041d19f0e2c00f5"}, + {file = "multidict-6.8.0-cp313-cp313-win32.whl", hash = "sha256:093167d22a8c95af30f597b8a5686f20a14512989942d4be804d119899caca20"}, + {file = "multidict-6.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:f25b61a708bd276e8cbb6afcbbf1b8e793a3be70ba0a842d0b8692020f83b706"}, + {file = "multidict-6.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb36381e1f9f9d06eba2f10bdd438e5d20c07d5b55e1a3eee30b9f44cbf52316"}, + {file = "multidict-6.8.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:f8b09b25e0f4dc2ea9e2adbb1cc3ba11a94d6fa3dd978ae659c8743052e1afbc"}, + {file = "multidict-6.8.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1f57c414be82490bc0e0305fdb834186229b2d9b6a35fa0afd1eb1a772d125ab"}, + {file = "multidict-6.8.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:00be37bde741bf60871082cd347a093218c44886e99231b7516671c70f2c280d"}, + {file = "multidict-6.8.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e37b744849fb631bb52e3dadde35ffeee365a6c41cf71257b5b7acc9cd83fd38"}, + {file = "multidict-6.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c2b2a96cf1dd99fe7867be4c013314225f4d5786e6685906e29932d42aca6f11"}, + {file = "multidict-6.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bea7df027015856ba5d0a88e3b4777ff8cb5c66b58fc108050fe79d4dd9d4d2d"}, + {file = "multidict-6.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d3da668e903c934ed0b587ecacfed6901f6ae6384a6e975887592b61845e78bc"}, + {file = "multidict-6.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64eaeda36ee8d88f9e8616a587a8c66a663283cf6e0dcf013c1ddd8c758e4aef"}, + {file = "multidict-6.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ac746cb365bac1c462da9e3e6ab8904a8efe2217a56b0b2e3d9480f41d2b2602"}, + {file = "multidict-6.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18f0e06360c3e451a3ab800355773c8d125a758238d780c800b0ee5e90ee903c"}, + {file = "multidict-6.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69708fecaa88bcb2341397b49fc95057a835b02a3670c551b37f95dd79e64e3a"}, + {file = "multidict-6.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9606f583e7acaf61e7b3f56074e14037b9af7cb194590edfc0114b3ae5931ff7"}, + {file = "multidict-6.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1f66fe6a021173d0d47968491791966b9f3e6d61115f2491744aa0c07a6e67af"}, + {file = "multidict-6.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:be007d1aee2cbd530347dcafedb400891a3b5f1bd7135f95cf5d5b330b5219ee"}, + {file = "multidict-6.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8457aff3c12a89a8e1c4674de5c777857fbc429f40fe117a3d29538547cbc364"}, + {file = "multidict-6.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:95c27b4f3f04320fc44e338573f40c5c956b504a7fcf081a157fd0b02579311c"}, + {file = "multidict-6.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d244cf6b52b5ba1c34c3832f4652a668ebb36d95949b96eed9a1c54d916a90dd"}, + {file = "multidict-6.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5bbbb696c8024475b1877d14ce20d5f1cc05b8f6d786cea0fe3aa7fedc02e891"}, + {file = "multidict-6.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:cbd86f9787c5e2f5fd27d8b21458222f107347c6731c4e93dde68f554b466a2d"}, + {file = "multidict-6.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2f8a4b0b4d639d525928c7f30de527bfdf9ead6e44a5e8cb9c50aced5e4590cb"}, + {file = "multidict-6.8.0-cp314-cp314-win32.whl", hash = "sha256:8890c89d662560e51c55ac1304d6f919b23942abe9ae1127cb1de9aa6132fa52"}, + {file = "multidict-6.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:45cc39ba50fb0754a4359b90f8229ae08598fe2266abe3521b4e5a9ba916534a"}, + {file = "multidict-6.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d0264f8d5cb0a803f650a6a8572dfa0cd1e099a2234c588dc8fb220b415b865f"}, + {file = "multidict-6.8.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1969971900b0871530f9b62280dcc2d75688e74d2a69262bc01faf2b96c78f04"}, + {file = "multidict-6.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8180b635290a75af8478f1b3e9810135381ae24833293fe77b85c1c21ff842ab"}, + {file = "multidict-6.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4261863fc8b5ab1b815ede94e592e94c6af5b04616014929057e61859e7382a9"}, + {file = "multidict-6.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0b143d53590e89f43153d81d505a8448d4d57354354385aef8a51d67ffefa27e"}, + {file = "multidict-6.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da1c112c5784ccd9d32cd90be6739fee32644e874eff6ae8f0497cba3e352e58"}, + {file = "multidict-6.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f7eefd0233a7c33ca980a5cfef26f1e9b5e2137839e752a99963696729f12d91"}, + {file = "multidict-6.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:348bb85e2038b40c007383616d73f734869063772372519549ebd7da1723d1a4"}, + {file = "multidict-6.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:095f62ea4e7a3be2f6c567ab695ce10e950f2adb905c1bec82281593e0b2d2ad"}, + {file = "multidict-6.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be569fff1d85cd29391c431c5641c8772acb75bbdc61e60a8e82fceb9023d385"}, + {file = "multidict-6.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3533a03e4e789baf6a286e7b0b1b6da3f3d7c3eab569686ee29ee1d8b52e2cb4"}, + {file = "multidict-6.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1bdb9b8fba5a9aef673ec90db3f55b1ce743f2fbdea4d37dc04d14ccdfc153ff"}, + {file = "multidict-6.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8d7b66c9e09c0bb0add2b5895e646b62a0849e71155066f215523de6b95cbe6"}, + {file = "multidict-6.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:563d6500ca80dac7bba6f48a78e0ffd87e21a7d4d24642c6503a2ddccd70c110"}, + {file = "multidict-6.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:346ac52e56bcda320c0dcdfdd081947ed7cada33afea4e2284bef7b0733bff9b"}, + {file = "multidict-6.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4ee953a5ebaeed38dc21cc032ed17a9d9782802e00042200497ab4b01b0bf7c0"}, + {file = "multidict-6.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2f79cc3e8039a8cf5c77e0811b0807953fd52d0863b9b76970b20d696dc64a78"}, + {file = "multidict-6.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43a4b56555bbcf8af161e7c7682bd93eec10f068c95844511864c018c8e5e13b"}, + {file = "multidict-6.8.0-cp314-cp314t-win32.whl", hash = "sha256:48ea524a25a1cd5972cf293bc95713918cba0bcd6fa9b992d906c857c546abe2"}, + {file = "multidict-6.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d0be2b832435001bc623ca7f1499ca1a853d4f082fb61221a80ce71132f50b26"}, + {file = "multidict-6.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:62b8e291a4f7edbf7cde7a43d831d893ba443a1b627498b53581943b0e348feb"}, + {file = "multidict-6.8.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:e192018b732f7b168e6604cbdf40fa8e05c996693b9eb445a0d8a73f4b77c5d3"}, + {file = "multidict-6.8.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b25426f9f6ed402835617c8f23609a47045f91ecff365eb6734817e039a8ed25"}, + {file = "multidict-6.8.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:fa6c2880709c84457de104385b704fc28860f27e442ad13966fc4af8e714fe9c"}, + {file = "multidict-6.8.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:eabb03dc3e4ed6333ecd1cc9826ec80e7a98b5506deeb832d7260c8e44166d23"}, + {file = "multidict-6.8.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:59e539c4eb4d3a53b0e630a6ba2b2f2824732b5e73f90e30a280f12fde157b15"}, + {file = "multidict-6.8.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:835d5a90b11d1f5f8200ff3cc8316bded76eebebc92436398947a27657e645e7"}, + {file = "multidict-6.8.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d2d236b8a44ae91536a12ebcb996bdb31cf27425f36b4d05c87f2ba2716050ba"}, + {file = "multidict-6.8.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb9a60b7faa5d37c426fa91cf4d6738182a1f2755b9fab7c9c64cd466c4ce51e"}, + {file = "multidict-6.8.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ef606c15cac6c90279acf34120784b6f36662cbf382defd3955cd8f1115336b"}, + {file = "multidict-6.8.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:68186a2d4051c8ffd17be33553bea2ec9bbc8ef860fe2980a221d96126296f31"}, + {file = "multidict-6.8.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2cc66abb85e2108c9ff8a1c0d20fa260bf690bbb33caef4ff3ecb2c2cbdfff5d"}, + {file = "multidict-6.8.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69b3e519a132bb943b0daae15fc8c2168706b17f826481d32a32a5e784b129e3"}, + {file = "multidict-6.8.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e41226ecf607f062fe34a2f4cf64ad3a89e3a0180dc800b463b6b14c06dd10dc"}, + {file = "multidict-6.8.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:610c7637bc36b90f39e6c66f710f93d57018f83d53e1e187caaa218c6892b95f"}, + {file = "multidict-6.8.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:65c85c79f5a2c04fbbc18f006c014674dc5fdf270cb978d8862c82c6f694e60c"}, + {file = "multidict-6.8.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:628ff11e6720f90acd0c305dfa3339f04a783a20de8cda6ac333ba46447261e8"}, + {file = "multidict-6.8.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0935971bffd0b479fc90c4811ca787703e93fcb6afea939a375dfc80285ab368"}, + {file = "multidict-6.8.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9442b14eec262a1f74369bbd07e75bc5155105164649a4b9fbc1ebc7b8fb0b14"}, + {file = "multidict-6.8.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:397599503b718f0137f26d3f6532d6955069cd2e5917c47ef581495bc2529ff8"}, + {file = "multidict-6.8.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9e37024b41d7a7e7e9cce14b248d54707c21c2a2ea30a47b71bdcefcafec00f2"}, + {file = "multidict-6.8.0-cp315-cp315-win32.whl", hash = "sha256:071da134651b04a8507dfb331ac0988f376337c2aea59486bf20989fb5b5a64e"}, + {file = "multidict-6.8.0-cp315-cp315-win_amd64.whl", hash = "sha256:3bafff8598f0528017ddc74194e5451d5c22d046c98935f8f86247b0f286e4f8"}, + {file = "multidict-6.8.0-cp315-cp315-win_arm64.whl", hash = "sha256:e886ef8c9879105fe4fc99417447b3a5f35d1131412ce839470bd2089fe2043f"}, + {file = "multidict-6.8.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:883284137e25318ed9735b742ae46341a864888fae28e8b6314c4f84da080f08"}, + {file = "multidict-6.8.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:ca52b9ec80851366197577154c862c4c4c7036ca76ae94cef5cb59c5cfeab944"}, + {file = "multidict-6.8.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:91fa75d0a693832106d98f66c849f034f21c828d14437f1fb97d3784aab89e84"}, + {file = "multidict-6.8.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:307c1acd812fe897e7fbe10c6758822e8c04be4e7c60a9f54901cdf8b5ab8bc3"}, + {file = "multidict-6.8.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf684986a2490628f059a99dd107b566a2d34cf947f8eb8387e0500a1f90c5"}, + {file = "multidict-6.8.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:77745725125d01fd613b6db043362aa7c6bfbfdb23d45dbfc3d92bf58160af62"}, + {file = "multidict-6.8.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8daafaa0b2eb43f76898ced78b1e0fb91b38c4fa50da516c18067f2a2d578c20"}, + {file = "multidict-6.8.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c68e0c0649d17c2d0339e3674e86a4aeba4a7e6b21c1e394cf947a95433b31d0"}, + {file = "multidict-6.8.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8a5ac357ac283490a8d1899b0383355fd1f8634b14ba0d59e4c0dd97db85556"}, + {file = "multidict-6.8.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:46029e6e27a3ec0dc55b53f58df82d10f04c5e111f78248279b530bedad2c30a"}, + {file = "multidict-6.8.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8d1046b5427dcafe6e8a0e07527dd74f1ee694006160162f53f3a17f15aad3b4"}, + {file = "multidict-6.8.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:f1f4a220db6ed7c8fd16b6d644ffd1f082651693204daf3275e049fadc849e39"}, + {file = "multidict-6.8.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:029897732a9c798737457e382bf84e8c64237eff224a90aea2639f4413c45e4e"}, + {file = "multidict-6.8.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:29be9fd289e9ab8f480996ea2f686e1654b80242033843cb11691688329423f1"}, + {file = "multidict-6.8.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:29631224698de1e42abc8fa7658d830e0aed0029785144b5832b695da5adef2f"}, + {file = "multidict-6.8.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:962f18c59a000f30b084ea2e6b8001521bb315efd4e5f10acf9fb36f366b7882"}, + {file = "multidict-6.8.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c60e50bc5b07faac92fd3a20fa21cc8cf3e3f7204d2867b206c73293ebc19101"}, + {file = "multidict-6.8.0-cp315-cp315t-win32.whl", hash = "sha256:fc5460940f50dff00731b4132366840ba9685286ea88ea104b661899084f3fea"}, + {file = "multidict-6.8.0-cp315-cp315t-win_amd64.whl", hash = "sha256:b367c342327717d644db4c0ddb37ceb655c84822215ea0773a3a36911b74b71d"}, + {file = "multidict-6.8.0-cp315-cp315t-win_arm64.whl", hash = "sha256:0c1c4debad7337627b86837abdf0237ca3cb3d7e17de7eab0177c263878546d4"}, + {file = "multidict-6.8.0-py3-none-any.whl", hash = "sha256:75daa15ca16d6285eb2e104b2f05ee6f8d9836c68da3ce5c85f615a0450eed0e"}, + {file = "multidict-6.8.0.tar.gz", hash = "sha256:5cd4637ce76312ba1e05eb9c5193fec231f64fee0944e135fa1e951242355b37"}, ] [package.dependencies] @@ -774,6 +739,7 @@ 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"}, @@ -827,6 +793,7 @@ 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"}, @@ -838,6 +805,7 @@ 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"}, @@ -849,6 +817,7 @@ 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"}, @@ -860,132 +829,151 @@ testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "propcache" -version = "0.5.2" +version = "0.5.3" description = "Accelerated property cache" -optional = false +optional = true python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"aiohttp\"" 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"}, + {file = "propcache-0.5.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e443bc079b57cb231ed45a8a55879a868bf56449ca7a719e58988375f828d8d"}, + {file = "propcache-0.5.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3ecda25c86ee7500b5348cb1e8831ee4324daa45ec7e24265808121537c85bbe"}, + {file = "propcache-0.5.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:31239c475a43568c0d64d5e10208f449e004ff4894458507fed5ce53403ac3a2"}, + {file = "propcache-0.5.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5867f7d672f239fe70569c947a4453ed44f82c166ff08d4abf6cf7350d9a4b12"}, + {file = "propcache-0.5.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:544f08889abce275a77b27add29cdaa87a36e6ce6abeffcb4e87b4e29c577711"}, + {file = "propcache-0.5.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:58515aacf6ba8ad47a0b072ff2a27ccb39d7691ed9101636e6b5e151758b11c0"}, + {file = "propcache-0.5.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e61294a7d9b07a79b3c670dbceaa4aca37542e68cdf9411e04ed81cabb2f62a5"}, + {file = "propcache-0.5.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1956d7e98f2150e4b0aae71dccf61fb5628208c63d57e2dafae68301fe8e1a36"}, + {file = "propcache-0.5.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:79af5bf10e5a631ba601c33f379e37e64f036c55e60a99254ec55ab4678d268b"}, + {file = "propcache-0.5.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:400db553b2a2af6ea41ff3b6d5972fe493180fd519c1d001c9581fd446bdc150"}, + {file = "propcache-0.5.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0275013737080d5d113be6927ca6055a3e393a9d95f81667c4bdb927915aac2a"}, + {file = "propcache-0.5.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c2fd56f3b358f07e4fefcf54942f20f64e905db63dff8fddf0c67730c748b3d1"}, + {file = "propcache-0.5.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8e507119af1a2d417d75ebe202bc0a2a282ad0a3e6cf38f447a6cad0a2c5263e"}, + {file = "propcache-0.5.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d8a41e6f549e8bc13dc7b4987f5e96097bc2fefc66b682b3947a8e959f75a9c7"}, + {file = "propcache-0.5.3-cp310-cp310-win32.whl", hash = "sha256:e8e6acf498ee3d0a5c2a8468e23af7acb017c5b219b29e302af2636818d3830e"}, + {file = "propcache-0.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:09513990e2f33c9de838f0ab11b62660b3ea225000945d10143007e51539088c"}, + {file = "propcache-0.5.3-cp310-cp310-win_arm64.whl", hash = "sha256:5964c7e2f632facc552ab0e163cac05e1ce42acba20b4b24d445139629ed7625"}, + {file = "propcache-0.5.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b7edbbf2d9ba5829663dbc83cdb6e2b13bc80cd36f0bf185419dcbbb4930af7e"}, + {file = "propcache-0.5.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d2c6ccfcc810ac7f8782593934fa2215669a401ef43fcae5a8cab80ce4754a46"}, + {file = "propcache-0.5.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2f1723f7a060a20eb1f9971fa69c9931b5ebd253bef8baaa48a1cbd1c01c7bb7"}, + {file = "propcache-0.5.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba1ae9d55025434790882c2f1be727e725ccef83876a112af52247c7ed7ce321"}, + {file = "propcache-0.5.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30f9d52fdd02a216601f588e5659dd39cb7d2a0ef59935d33c5077718201f8da"}, + {file = "propcache-0.5.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0fdd72f694faf2b34907484b53e5df7ae6b5dad6a6e03564c6b872ccebb80a7d"}, + {file = "propcache-0.5.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:35de8d67f6b6a3830f845ee7cd3ae47189d7dd66088aff9dfd1fe50f5bda5a60"}, + {file = "propcache-0.5.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:377011845a2b1a8b11ab2ca33547aea1320d6f70c015bafe26486f0882dc49fb"}, + {file = "propcache-0.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2537fa4f56c12fa897f9371e4e5910417aa49ff916a7dc549a22061b46d33355"}, + {file = "propcache-0.5.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3eb545ffc0db5c86e83ba1aec3616efbaac9c22f05daff095005472874c12e91"}, + {file = "propcache-0.5.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:023d4e58beeb0865cfc62e2e14bf4ed30e21dfc20ce16ac24fb05516a09e1d18"}, + {file = "propcache-0.5.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:d693b78ecb4df14b3aa636d9c2a40e5ab3b4f81a3bb569fddc2b28299f1ff0d1"}, + {file = "propcache-0.5.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a5ea7cf6d6a0b67a64e6744219265bc5406d827ca3b46ae2d748c3aaa1d5e1d2"}, + {file = "propcache-0.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:66be2cef41263d823517ee617d19fd105a70ff58a0776e5e47cfde1196bfa2f2"}, + {file = "propcache-0.5.3-cp311-cp311-win32.whl", hash = "sha256:d8eed2e84204b4402407a5d1ca124cb653b9a6ba2fc01ce1ef1071a75ac9e176"}, + {file = "propcache-0.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:e01abf74acc057c0ef8533d5688a2bed25923cf05c450a0d82229fa538cf83c1"}, + {file = "propcache-0.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:ba7220f33a3df68245b09bc519e2c3f9f5c4123ea68f007c11529ecac8615200"}, + {file = "propcache-0.5.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eeb4e5a10149960ed021b8e35fa22440e106db6322a9f5249ad8992f38855ceb"}, + {file = "propcache-0.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b09edf2908fa5f0ba84fdb9c1bc6f346945b51541013ac29b12d20218f213279"}, + {file = "propcache-0.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2db81e15c4e0171ad26845b67a1e295ba446e717e47fefaf47f22ae0b2eca71e"}, + {file = "propcache-0.5.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50079e8be4f1bc33e8265f35fbe6449017901db626bc6b735181568c4bfb5e73"}, + {file = "propcache-0.5.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8ea844a859c3060a44485c4e842df3ead69ca98ca5550594757aac11dfef30d"}, + {file = "propcache-0.5.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ec76f86ec2de069358d896c468be3c1cb3b5ed049e3cec3ecb3015806313606"}, + {file = "propcache-0.5.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a36f13d838accaa887f9e54af4866ae344a51eb59b99dbd6cfeee701fc98ff"}, + {file = "propcache-0.5.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1a536910fbd2f4ea76acd6b1b50708ce602a27673ef5e5b2c8ba373c2f5127cf"}, + {file = "propcache-0.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f95a390defc963a5543012685b57eb9cd47078f50c4a3bb5c5cd7719c4175112"}, + {file = "propcache-0.5.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1d617a68a5f383855af3eec608ddd98dd3b964184011f20755a4e34acb194a5d"}, + {file = "propcache-0.5.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:28afdbaf9f1bf34bc719e0381a4699705990c59f412e43ff25f91a01ff6676af"}, + {file = "propcache-0.5.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:014cec8c711df6f22e0a50cfcb3e98d3d659856daa75e9dc2f1e206204027519"}, + {file = "propcache-0.5.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ae72c39267ffc08bab9239fbdef5fb8bb0fc410f141b1ed283f0dd349b8bcdb0"}, + {file = "propcache-0.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:22e8849a90d46d920469eacc52cf28dbfce9d9dc7fb0ee36ba3aa4b117fc8311"}, + {file = "propcache-0.5.3-cp312-cp312-win32.whl", hash = "sha256:71dbe01ca877ca1c7d61f0f83b71f2c994554ef52983fe7c6400d9c483c477cb"}, + {file = "propcache-0.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:2367fbfaf980a0ac082c64b53ae669ee704515134a28cd8c680642bd90a2ddd5"}, + {file = "propcache-0.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:646d9d567fdc8f1f8ab3e785f8702175a9212ea3432af2506f2f914f21c3ae16"}, + {file = "propcache-0.5.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ef59cf75b4d63fed6308d933db810b05f0738925b1470b6caad88afed597c7b1"}, + {file = "propcache-0.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9f10fce5b91adc67a566817365381279b6f4f3b19fbb58eacd2ab8d045e75492"}, + {file = "propcache-0.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bc469f68cc978501b6ec69b49f67738ae099064002d0bea283c491d086e93a27"}, + {file = "propcache-0.5.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f683f8eac4e695c48a3e24a7347b392322219b6a99351d7b6bf70c9ef2262126"}, + {file = "propcache-0.5.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9fc1d60bc45efd3b1440a17e4a7eea268d34c22eb30ccec10246b5b6833a603c"}, + {file = "propcache-0.5.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:384ae10f8d6595595361566f9073578c131b1eb7c651bce5d78c2c0d411a221f"}, + {file = "propcache-0.5.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f18065f005006f20fde9c08d21b375d52908b72c493e9b2531dd25da89a8ef50"}, + {file = "propcache-0.5.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2c73e4520974b55081770703be7066d3b346db16abe399148a154be0f9f92c81"}, + {file = "propcache-0.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0e55ddb8404887d1bffb0937287cc27c98a0f1a4b91453b21b9d5c9df0743377"}, + {file = "propcache-0.5.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5b90847530d99dfc9ab31a6b4432fe92ad9868630b45acac21bb08468ea82614"}, + {file = "propcache-0.5.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c71593d15b601f785e4f0362822d3101b999e4a696210281e83e035abb8c33ba"}, + {file = "propcache-0.5.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d3f6e1a3a0efc4ea71b8dd4310c4c5693686ad0c3b4c657beafea3aaf6a28428"}, + {file = "propcache-0.5.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:45115e5e32fcc253de3f14633ca1c982239c9ba37ba41ca45a7e30b3afeb3b77"}, + {file = "propcache-0.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d7d377c2e8ddd7b7254c3ec6b23275bfb9d307a32bef017ba7dc94b94f45c509"}, + {file = "propcache-0.5.3-cp313-cp313-win32.whl", hash = "sha256:442d4a18df8be3ceff91bcd8eb850c263f36726287327499c68e72482ac07b48"}, + {file = "propcache-0.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:a8e2d2bb4285a5fa8e5c01465b5379abf8f244d4978855558432987ef62672f1"}, + {file = "propcache-0.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:82ff2cf7bcb0c207ad1d207e1a51ea1d53aff47628729c7368c5ead6924d3b7f"}, + {file = "propcache-0.5.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7ee15901025c40b58ee5547a7c244841701c09758f3e98ddbb0b876d96d52ad4"}, + {file = "propcache-0.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:37e41cd6c1a27ddbbd0b556fb0c02ae8e55c41c8a122c83f1350a017f0bbc69c"}, + {file = "propcache-0.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:04e21d1eea62765ee0306c80c61937255a5cd99c9c56d6cb6100ca5794e8b2f3"}, + {file = "propcache-0.5.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f698081e39d0a63f44ae35da7c43c595f9f60d26ee08e575576fd7df3029e31b"}, + {file = "propcache-0.5.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1a8588f64d4d5d6e65d7faa9e0d1dce9fdb25e50aca194cffc6106deaf9d320a"}, + {file = "propcache-0.5.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69f226874a50eb2fdd0db47303b66b1084a545aa1d68d45ce2a1c9920cf2ea00"}, + {file = "propcache-0.5.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfa60b1dbd3c7881730407df60d2be83058ed145701b5c51ff113b7a3ced2224"}, + {file = "propcache-0.5.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a78a241677f693485ee6c0165ab657341e7ecac93f8d53cd70771ed6f3eafb61"}, + {file = "propcache-0.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c5930611bf42350905ace62fc8306146cb644c5d43efba541e68f4d22383e459"}, + {file = "propcache-0.5.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a7f485b78c7ab8e8b9c3375d32d25a077d530cfc676c665e89685fccfa2ecbbb"}, + {file = "propcache-0.5.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1162ba1e960ea6dd0453ff3ae29328a37307486c260c93343782ea1d56eca6f2"}, + {file = "propcache-0.5.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:49f3c98e977785ea49a8b99463cd81f343c8307dc98fe485fb94288597d8c01a"}, + {file = "propcache-0.5.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8ef15c7a41364b098c26458dc3b57b7a5db6e042661c5cbeb75ed78e503162cb"}, + {file = "propcache-0.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:54f06f17c66add28dc79e314e3b2ba7a9612564314d8b2c27b2a8c2f270d33c7"}, + {file = "propcache-0.5.3-cp314-cp314-win32.whl", hash = "sha256:37a99cf741523fa5406ef2c60925b7ad616afd772fb3364b431a921404853d46"}, + {file = "propcache-0.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:10573db5b40f55728e0abd5fc47096db6162ff612af10430ae93377af09e2a4b"}, + {file = "propcache-0.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:9e561c183c72665752cc2ede30d985a9e80534f43aaa16fa6e1224d151ad54c2"}, + {file = "propcache-0.5.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:622ae786b1e3d46684b8866cd7415c9eca26f341757c4bfdbdfad8ee129a7103"}, + {file = "propcache-0.5.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a3af1190a9c62a0da8d90a0a613d6b69029e86ee60b599e146c1e79a80c77d0"}, + {file = "propcache-0.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dcc70083abd3374d7ec338c6dfd37030bc1e54ed2c71abe2c51f16c3d7af5531"}, + {file = "propcache-0.5.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:665451eaeb3e5e09129c4a789d63620502f50c16be25ebe9370f7dc05ed4d49b"}, + {file = "propcache-0.5.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8c8e8b2dfe7d7bbca58b12170cd65b4f7ec701e67812a853b7da8023f681c9f"}, + {file = "propcache-0.5.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b6239f0d6175da9d07084c74628af8baa258fe8929faf6a75ce425b0db6ec9f"}, + {file = "propcache-0.5.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:104e8d289159297822541566d60769b81af3bfac971509a91c8b94cb2554d7dc"}, + {file = "propcache-0.5.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50eea49bd9c874378a015e8363befc05b3545a0bb1312ed44ba895cd7c5c9b3a"}, + {file = "propcache-0.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0f0e69dcc0aa9bd4aa22c9cf3f52cd92ed2fd067f2df93e56dc1d1c98d0538e5"}, + {file = "propcache-0.5.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:9498e6b074f7e4c58fa518e8f87944f87e612ad910bedaabfdc07695d91efa8b"}, + {file = "propcache-0.5.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b3541fae8294f9129e3ea393d146f28c035acf9106ee2025121dbd2b7bb2516e"}, + {file = "propcache-0.5.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:105ad7d80740aa4d44f025a7f7335599e0b26faa4d6c80918069c16e108d29db"}, + {file = "propcache-0.5.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9851f8a7bd86a455ba5f32652b13dae69011d93e44d3433fed7eb54b10f3d9e2"}, + {file = "propcache-0.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3745e60d95e52a782b1e99ecccc72d22ee61c86c1e368647fff0444b9ec4bb70"}, + {file = "propcache-0.5.3-cp314-cp314t-win32.whl", hash = "sha256:ec2e387ce38d9b73054a7e96d70d22c8471a83df6205019442394b079d26a84d"}, + {file = "propcache-0.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:8a3396b1822f0c5291a11bdf93877d3118d5cafca5905e185582beee039fa508"}, + {file = "propcache-0.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:0dd0ef45b87c5a59970b42dd70493b3c022be490a2268dfd391cdacdfb9a611b"}, + {file = "propcache-0.5.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:888624d71be1029609ad7f333be31b0fa3546caef67a5b17ca2fc7f5e07c8168"}, + {file = "propcache-0.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fcf51bbb689ee4c15226ab80c96c1d7804cb1f5fdbbc828b7f75f6f6ee5a172a"}, + {file = "propcache-0.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:73b3e3cf2b6b6af3f407439b647c371452481d53b786b75e1fd0956e1d69b59f"}, + {file = "propcache-0.5.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23788073f16c2e0706f5aeaae8489ba504027cc15b22f78bdd55df8e6b5f5b37"}, + {file = "propcache-0.5.3-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb98bd17ad50a99f9f6962214608afdb92a0ebef681a5f4a5d38024578176df0"}, + {file = "propcache-0.5.3-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c95b48cda71997707271ec146e13450c8a27d57ee5d01622a0afb88a0f5a6b95"}, + {file = "propcache-0.5.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50750f402638c9770e33e7e9ff8aa1d5115de8ea07ec29898c85abfa47b0f442"}, + {file = "propcache-0.5.3-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0506edf85bec8997f5084584f2b863a27528147c59aab9bdaca2cbee0e22bb76"}, + {file = "propcache-0.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e9fa0ec1edaa4a9a95d8e0ba4676f43f187323cc5249d8217b3d75484d922f77"}, + {file = "propcache-0.5.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:1003b8557b64ce8c09f4c0193be582231805997d6e74ca0e9c79eb7b375789b3"}, + {file = "propcache-0.5.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:282cb412e862cfc23ab796addeeaeefe9ac85061349bf474249f7d77528135dd"}, + {file = "propcache-0.5.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b6d618b5144fcd65ac04113cee9196525dc10c23c085f27f3c2c13de439d4853"}, + {file = "propcache-0.5.3-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:3c996ebbee2460fe77be155dd42ad4506fbcefed7a4b9b776b3894b0f52c1479"}, + {file = "propcache-0.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:95783c8fd4ec500c6afdcae87e9bb80c7379ef4dee5fcdf27c88e08f0c19621a"}, + {file = "propcache-0.5.3-cp315-cp315-win32.whl", hash = "sha256:55dca8dd42e5c12d9b7949a50df9a283e9720d26899f6ee53208f1919664812c"}, + {file = "propcache-0.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:f15dd057a45bcf50a000d515ca5263d567c18ebe7de85c71687fe2d88fb0dde3"}, + {file = "propcache-0.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:07478d53a45cd33a9c721cde42a1074dc7fed8fc5562bcfd83172d5b1f140ee8"}, + {file = "propcache-0.5.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:18ae303e7ae34fb2fdc21d190ffcdf025b8907e50da5393ff271b66fb08eb123"}, + {file = "propcache-0.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:fd0c772d8f7d0f0cbb73271c64f658622d7c143c5696462c918f73f3a7542c2a"}, + {file = "propcache-0.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1864f5637f61cf3fb21d5351d494de0b896f9867c8b4f000b8301c7468c6a05b"}, + {file = "propcache-0.5.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40cf37a31664e280fdbb6f8ea9ee5a16bcbe504c464f210d90f2e8cfac4c570c"}, + {file = "propcache-0.5.3-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8f0583da2556ac23e8eec959c45c608d7b5dce3c5e7637a81af695355a57b6c"}, + {file = "propcache-0.5.3-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f49d1c07dea7235efc959b72ceb592cdeb44b0f60ce69828af318c40ada3503c"}, + {file = "propcache-0.5.3-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:67af78cf66472e82c8beb672930f6279d08c0007ba4cb675a70cee5cff1fac88"}, + {file = "propcache-0.5.3-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc601955fe451749386dc4d7f3e7f8f1d4db6846c8be4582c5ee21ee0acc7559"}, + {file = "propcache-0.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:bf82eea5664b0b1323d2304c68e84215a5b3a11bd9cc733df09edf7fbae7fcbb"}, + {file = "propcache-0.5.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:d9eb3b83d2c106e0a1bcc788dc4d375cf60d4f1a4bfb151858fb84d4baf184d8"}, + {file = "propcache-0.5.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:b8149edcd93da046f6f1e3dad1df51c6257bef13f8171aec5b8aebbcba840f2e"}, + {file = "propcache-0.5.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:02729b64fea4ae4d9a5a9a94ba56f360978b1538bd347a81df19c3e5520d1ecb"}, + {file = "propcache-0.5.3-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:98bf605793ff20856a4bbc8af2e5ddbbf6cfefdf6b5cdc01587627a3e7057004"}, + {file = "propcache-0.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dc88c7b971999389dc5ceea90d8f346fb360888549e123644fc63b90dbd7f64f"}, + {file = "propcache-0.5.3-cp315-cp315t-win32.whl", hash = "sha256:1102c4f4390e6a50f1960b8cb494007f08f5a139b39fd278925330e622b03ab8"}, + {file = "propcache-0.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:09b5c52fec3f50ced0c5c810f02c1237d7c89a8f3f5345ad92c6a49faeafe6a9"}, + {file = "propcache-0.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:74f50a14ccc90bf5e544521783aafaf8081a21eeb24fbcb295ecb79d766a140e"}, + {file = "propcache-0.5.3-py3-none-any.whl", hash = "sha256:abcef8e75a2c324dabd46c842c17cb005ae968596a567e43dfd6ee074f9f907b"}, + {file = "propcache-0.5.3.tar.gz", hash = "sha256:5f283fde8fd8b1944fd1a1bd11db759b55c5f190795668d5187d3e3b1165d98b"}, ] [[package]] @@ -994,6 +982,7 @@ 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"}, @@ -1007,7 +996,7 @@ typing-inspection = ">=0.4.2" [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] [[package]] name = "pydantic-core" @@ -1015,6 +1004,7 @@ 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"}, @@ -1142,60 +1132,63 @@ files = [ typing-extensions = ">=4.14.1" [[package]] -name = "pyjwt" -version = "2.13.0" -description = "JSON Web Token implementation in Python" +name = "pygments" +version = "2.21.0" +description = "Pygments is a syntax highlighting package written 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"}, + {file = "pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9"}, + {file = "pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c"}, ] -[package.dependencies] -typing_extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} - [package.extras] -crypto = ["cryptography (>=3.4.0)"] +windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pytest" -version = "7.4.4" +version = "9.1.1" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" +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"}, + {file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"}, + {file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"}, ] [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\""} +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", 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"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" -version = "0.23.8" +version = "1.4.0" description = "Pytest support for asyncio" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" +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"}, + {file = "pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1"}, + {file = "pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42"}, ] [package.dependencies] -pytest = ">=7.0.0,<9" +backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} +pytest = ">=8.4,<10" +typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} [package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)", "sphinx-tabs (>=3.5)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] @@ -1204,6 +1197,7 @@ 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"}, @@ -1224,6 +1218,7 @@ 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"}, @@ -1232,31 +1227,13 @@ files = [ [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" +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"}, @@ -1284,28 +1261,20 @@ 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"}, @@ -1362,6 +1331,7 @@ 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"}, @@ -1373,6 +1343,7 @@ 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"}, @@ -1384,6 +1355,7 @@ 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"}, @@ -1393,258 +1365,170 @@ files = [ 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" +name = "urllib3" +version = "2.8.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.10" +groups = ["dev"] 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"}, + {file = "urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3"}, + {file = "urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63"}, ] [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"}, -] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "yarl" -version = "1.24.5" +version = "1.25.1" description = "Yet another URL library" -optional = false +optional = true python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"aiohttp\"" 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"}, + {file = "yarl-1.25.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:142c06c4d6a35ee3ec5da08499805e879cb3ca7c1fbfbecb0140fe72403818d6"}, + {file = "yarl-1.25.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:24ce942011a61953e7d313438038f4d32ff21387b775f58a957f7a07dd55ef95"}, + {file = "yarl-1.25.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e23c82b63cd7652fc24d33ed6cc17099d607aa3b4fc4ddc75e95062f3d82df4"}, + {file = "yarl-1.25.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee202350cf57abf0e9502a41601841019c25d3db7ff52d980aaf31446254059"}, + {file = "yarl-1.25.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5df89f769cc8ff94c3d7e7603386fba309d25ce5240132d26c15baa8d0e96c4c"}, + {file = "yarl-1.25.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:83e9f4a25085bd4b7214701a0794ff1f50fc633ffb8bdfebf07abdd81c2db126"}, + {file = "yarl-1.25.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e636b64d24fd9c38053c5e389a1174c66361fa49dcfd220f4dd35b4abde7cb89"}, + {file = "yarl-1.25.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e5637ca8d0bd7fb72648a6c7934af4baaccb697657f7438c9d264fc2abb8b0b1"}, + {file = "yarl-1.25.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:683e362b8ba453080f7489c66f4ea794e751c35b72e7eab3575ef784c2fbc7fb"}, + {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:df23df54b5114a17c2d0ef192433e2e5a9f0c5178c32375e90b7cfc965f349d0"}, + {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f53dcd26694f148f738edc052b5a69234833e739f10f4c3287bdfd8ec0f7b326"}, + {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b8075fe90bc08e40b8b8a1874fab42ee4c7b56af05c5886e9cc841397f916908"}, + {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a8c2b841478068440d8b733005d13a5ef535b9928cbc05f17182d410f32ba449"}, + {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ca32926d7d77bcc8838425c4c95e040a3ace1cb7dfdae599013458dcda2607ca"}, + {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:192a866877a49993949ef1975864ad8728bea28ee810f6abe1a0729c2b500426"}, + {file = "yarl-1.25.1-cp310-cp310-win_amd64.whl", hash = "sha256:3f4d48a6112712973e676bd792121fee470e432d749177162d9949d5c9460a1b"}, + {file = "yarl-1.25.1-cp310-cp310-win_arm64.whl", hash = "sha256:48796ea00a303961507dc6c8437c4b325a6fc3f95f7c36c71b91ea9a8150963c"}, + {file = "yarl-1.25.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d693bf4bf534e9ba3ae2780cfd577f5135629f7b5ac653490859d0b77864865"}, + {file = "yarl-1.25.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab2054c5531af2a9ba7b69b8ec91e4f884420e83a8c5e579b013084cb57e5e5d"}, + {file = "yarl-1.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:564fdc7085d2245ab84f88882fdb1d6ac0723124bff6ded35bfb1c00f812630d"}, + {file = "yarl-1.25.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acae6b45d1ace09b6ba3876da43b88366ef368f73b988c7f57e14231753d4420"}, + {file = "yarl-1.25.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fb2a01ba8cd9c5d2c5dc1ec35e0fc951d04b4f037541d4ac090c993ce58b3d7"}, + {file = "yarl-1.25.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e92b6bcc741b86d67606c40d3cb9c7cc8e6c737f81e31f4a94efc204456c92e3"}, + {file = "yarl-1.25.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72c34ac7ad4314c19362d5ce27626dcc8429bd30bbf8c179f4234078851f9492"}, + {file = "yarl-1.25.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5add7b4ca7afeea91d52e4d4e4db3b1fe9885b71f07054560d8c4296b7441a2"}, + {file = "yarl-1.25.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:def538065f9e4d4cf1ae164bd59aba00dfa84f03923e0de4c3788f252d6bcd17"}, + {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de"}, + {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:71f42c5b9a948c113bbdebfa544598321431d064ff959d32e99b1feb61d68345"}, + {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:72849d892954be4d09e569b8b831ac39ce58417fedc767d4308a0fe542018a40"}, + {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:efb01a106f971cb3752856bca2318bbdf7f01bd8823779c461586cbe5ffd5258"}, + {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a1daf47cd95a7c3a63456336bc5aaa8c86dd3a47d07ed3d0e76132ae4666a5a1"}, + {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9489e6abf47ba37f332075a91444c7cfedb03e6ce99fbb2f116bfe1ce810da3b"}, + {file = "yarl-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7306dee25b8a0e737363f347362b875094b4dc4e367311470656ae420fdbf8e"}, + {file = "yarl-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:abb1384477f5901d436b5d2e5465954de46ea6098f59163d243660b5c4461d35"}, + {file = "yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4"}, + {file = "yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d"}, + {file = "yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707"}, + {file = "yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03"}, + {file = "yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85"}, + {file = "yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5"}, + {file = "yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26"}, + {file = "yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec"}, + {file = "yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc"}, + {file = "yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184"}, + {file = "yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651"}, + {file = "yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df"}, + {file = "yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab"}, + {file = "yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3"}, + {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a"}, + {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b"}, + {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a"}, + {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a"}, + {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98"}, + {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa"}, + {file = "yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9"}, + {file = "yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe"}, + {file = "yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e"}, + {file = "yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f"}, + {file = "yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac"}, + {file = "yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c"}, + {file = "yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c"}, + {file = "yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743"}, + {file = "yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f"}, + {file = "yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57"}, + {file = "yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453"}, + {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc"}, + {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f"}, + {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8"}, + {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868"}, + {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62"}, + {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233"}, + {file = "yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b"}, + {file = "yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414"}, + {file = "yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa"}, + {file = "yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287"}, + {file = "yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94"}, + {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9"}, + {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0"}, + {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa"}, + {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b"}, + {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6"}, + {file = "yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee"}, + {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0"}, + {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7"}, + {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b"}, + {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d"}, + {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3"}, + {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3"}, + {file = "yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371"}, + {file = "yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6"}, + {file = "yarl-1.25.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b1bdaae98bc016825dd3c9d8ee1832f829b3341f9cc6ebd1a1b0a7fef7367cc"}, + {file = "yarl-1.25.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e7011b8fb8c4054bf0c12e5edc6cd83778b0028e99ce59b18586ed036f92cfdc"}, + {file = "yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b"}, + {file = "yarl-1.25.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d42e7e3ca399555578b4d617e3a6ecf13371b3743a115995fa010c7bf341459"}, + {file = "yarl-1.25.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa4ed3dd308548f9e707d9caaf005d2d7f8c1e7868f858dfeb47fe76e16b391d"}, + {file = "yarl-1.25.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42a66563d8cc056ee32e6191e05097a7b2b3bc302e0bc3133daf8710eb18bd26"}, + {file = "yarl-1.25.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98d370568f393215d605304cdb77b3d5539bd192c75b623c7304c42c8d6d8273"}, + {file = "yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf"}, + {file = "yarl-1.25.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d45673badd08456d0340e9364eddafe1c53a9d2896424294de4d7dd71ad3ee57"}, + {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3"}, + {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:59ba3a6e1aa8cfe5adf4bd270fd965db21955401b7ca6f1696010c55ed4daec2"}, + {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:87796fedc3ba97ec14fab55acb48584276e6c1e4c1e89c422bda62c838e754a9"}, + {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bd0912757081f89b107d6c00b2ff8a194401b0b87eadcf4481de2b865a8fd44f"}, + {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b51c159a9794633f5e0db7ecec7b2b6e3734eca1f5d17dc989ff3552a43ff78b"}, + {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:319e070a01db9920fb63761843f96a104c8e2b9427266731810dc1e22595b17c"}, + {file = "yarl-1.25.1-cp315-cp315-win_amd64.whl", hash = "sha256:a2059a2d891bd156bc5184e7ab7a56e78a84dfcfdeac8c501b552533ad1c36ee"}, + {file = "yarl-1.25.1-cp315-cp315-win_arm64.whl", hash = "sha256:a78b50b4f7918a3de71105d5c0b93bbc57bb8339a4d03a9dfd449f9068e76f3d"}, + {file = "yarl-1.25.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:b5402a340723fa7da00b5cff987ddab61276be6d11251ea71ae02bcac54890d8"}, + {file = "yarl-1.25.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:eda19ea5ee88742f47a2340816e6f2d40b53bed3ab5b69794769f36af9f35bb4"}, + {file = "yarl-1.25.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:75baa6cf9b6d1c52f3e111a130e202fd8cf0a5b3a066c3f73d615e885092e4ec"}, + {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbcef5a9119ef653653132cccaf999b30a0af6f33bb0a4ba80bec30056868487"}, + {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7efc9f082dfed77c316edffa9deb52888e1bc6789171887cc1f68e06d65465c8"}, + {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe01645169a2112aa1d4ebc3e4c5f029c5c8f97adfc32e5d37c993b39a994d75"}, + {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c557dfd5e3db046053a0bdc72261ade790ebe8e2c7a41b36b0ca1f14cb95f3"}, + {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ce4d6ccafb33d39bd78444612d14938ead674c25702ded2ee9c54a47735d225"}, + {file = "yarl-1.25.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80a063f8297fc796296f00f100be520f209b23dc98f93ce8eba6ee7122598209"}, + {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7cb414a73e21a7ab58254926073f2930cb22f5b4314ea4260a687e2b3fd4dce3"}, + {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:85a18376073f8a39aa07be34f9fc77e2869aa72c55c441efdd2cf79a0407504d"}, + {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:77716e245c90f058466a05e6a465bb8600f767a8f4b18b4d40f3aff958e5f73c"}, + {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1e80dcf1446e1b080b1932b0d103c464a04112f5bc31f0f983ad418172063cde"}, + {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bdc8d8b8c22e9e43ac68316b5e6cf083dec537f4ec213cb4aa967b583bc3fa64"}, + {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbf531053a0935f2e871bcd4753f90313688772ff8c017f5ea402e315a78c1f"}, + {file = "yarl-1.25.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b13b88747769537f3d32e89e3a735da10c0a9e35d7322928c701b5f93d3afffd"}, + {file = "yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25"}, + {file = "yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3"}, + {file = "yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7"}, ] [package.dependencies] @@ -1653,11 +1537,9 @@ multidict = ">=4.0" propcache = ">=0.2.1" [extras] -aiohttp = ["httpx-aiohttp"] -datastream = ["wasmtime", "websockets"] -rulesengine = ["wasmtime"] +aiohttp = ["aiohttp", "httpx-aiohttp"] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = "^3.10" -content-hash = "b97aece3e401780396eb371b240c4c82f59799ccece668843b9f37dd9b0af950" +content-hash = "4eaeff06fb8e558535aa3729ed6f99c2398eaaba665bcdf9c096bb08d9cb5fc6" diff --git a/reference.md b/reference.md index 34ca7e6c..b0bc7f91 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 4ac829cd..85dc2444 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/leases/__init__.py b/src/schematic/leases/__init__.py deleted file mode 100644 index 6dcb0832..00000000 --- a/src/schematic/leases/__init__.py +++ /dev/null @@ -1,73 +0,0 @@ -"""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 deleted file mode 100644 index 5793cfa1..00000000 --- a/src/schematic/leases/check.py +++ /dev/null @@ -1,526 +0,0 @@ -"""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 deleted file mode 100644 index 141b93be..00000000 --- a/src/schematic/leases/lease_manager.py +++ /dev/null @@ -1,452 +0,0 @@ -"""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 deleted file mode 100644 index 0742ff77..00000000 --- a/src/schematic/leases/lease_store.py +++ /dev/null @@ -1,270 +0,0 @@ -"""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 deleted file mode 100644 index 0accf652..00000000 --- a/src/schematic/leases/redis_lease_store.py +++ /dev/null @@ -1,349 +0,0 @@ -"""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 deleted file mode 100644 index be3c3e84..00000000 --- a/src/schematic/leases/redis_reservation_store.py +++ /dev/null @@ -1,278 +0,0 @@ -"""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 deleted file mode 100644 index 121631d1..00000000 --- a/src/schematic/leases/reservation_store.py +++ /dev/null @@ -1,125 +0,0 @@ -"""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 deleted file mode 100644 index 76c68ab8..00000000 --- a/src/schematic/leases/track.py +++ /dev/null @@ -1,82 +0,0 @@ -"""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 deleted file mode 100644 index 4a9d8a89..00000000 --- a/src/schematic/leases/types.py +++ /dev/null @@ -1,162 +0,0 @@ -"""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/src/schematic/planmigrations/client.py b/src/schematic/planmigrations/client.py index b244b871..d7a7907a 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 0765fe07..6e5571ee 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 26dd47e5..6764a1b7 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 122ff988..9dfb27c7 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 7b894784..9be7dbe0 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 d90e185d..e0be5b11 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 7ab426fd..5509701f 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 bebf39c4..e75cb2e6 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 a78a65ef..2855151c 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 diff --git a/tests/conformance/test_vectors.py b/tests/conformance/test_vectors.py deleted file mode 100644 index 3c3693e1..00000000 --- a/tests/conformance/test_vectors.py +++ /dev/null @@ -1,459 +0,0 @@ -"""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 deleted file mode 100644 index cb5a49fe..00000000 --- a/tests/lease_support.py +++ /dev/null @@ -1,366 +0,0 @@ -"""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 deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/leases/conftest.py b/tests/leases/conftest.py deleted file mode 100644 index 09213037..00000000 --- a/tests/leases/conftest.py +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index e7a7b555..00000000 --- a/tests/leases/test_check_and_track.py +++ /dev/null @@ -1,675 +0,0 @@ -"""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 deleted file mode 100644 index 43f9face..00000000 --- a/tests/leases/test_crash_windows.py +++ /dev/null @@ -1,207 +0,0 @@ -"""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 deleted file mode 100644 index a4c0dbfc..00000000 --- a/tests/leases/test_lease_manager.py +++ /dev/null @@ -1,404 +0,0 @@ -"""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 deleted file mode 100644 index 574a65dd..00000000 --- a/tests/leases/test_lease_store.py +++ /dev/null @@ -1,229 +0,0 @@ -"""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 deleted file mode 100644 index d184b553..00000000 --- a/tests/leases/test_redis_lease_store.py +++ /dev/null @@ -1,298 +0,0 @@ -"""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 deleted file mode 100644 index c6271a45..00000000 --- a/tests/leases/test_redis_reservation_store.py +++ /dev/null @@ -1,226 +0,0 @@ -"""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 deleted file mode 100644 index 10c5d2f8..00000000 --- a/tests/leases/test_reservation_store.py +++ /dev/null @@ -1,133 +0,0 @@ -"""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 deleted file mode 100644 index 90ea574c..00000000 --- a/tests/leases/test_wasm_credit_gate.py +++ /dev/null @@ -1,300 +0,0 @@ -"""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 deleted file mode 100644 index 6f499794..00000000 --- a/tests/leases/test_wire_client.py +++ /dev/null @@ -1,131 +0,0 @@ -"""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} From b3ca3995cae8da4a835c91a9613eb8c98e9cecee Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:09:14 +0000 Subject: [PATCH 2/5] [fern-replay] Applied customizations Patches with unresolved conflicts (1): - patch-f12c60b5: add credit leases, reservations, and preflight options (#116) Run `fern-replay resolve` to apply these customizations. --- .fern/replay.lock | 19771 +++++++++++++++- conformance/README.md | 10 + conformance/SPEC.md | 446 + conformance/vectors/check-flow.json | 527 + conformance/vectors/crash-windows.json | 271 + conformance/vectors/expiry.json | 198 + conformance/vectors/lease-lifecycle.json | 447 + conformance/vectors/lease-manager.json | 417 + .../vectors/reservation-lifecycle.json | 353 + conformance/vectors/track-settle.json | 194 + poetry.lock | 153 +- src/schematic/leases/__init__.py | 73 + src/schematic/leases/check.py | 526 + src/schematic/leases/lease_manager.py | 452 + src/schematic/leases/lease_store.py | 270 + src/schematic/leases/redis_lease_store.py | 349 + .../leases/redis_reservation_store.py | 278 + src/schematic/leases/reservation_store.py | 125 + src/schematic/leases/track.py | 82 + src/schematic/leases/types.py | 162 + tests/conformance/test_vectors.py | 459 + tests/lease_support.py | 366 + tests/leases/__init__.py | 0 tests/leases/conftest.py | 29 + tests/leases/test_check_and_track.py | 675 + tests/leases/test_crash_windows.py | 207 + tests/leases/test_lease_manager.py | 404 + tests/leases/test_lease_store.py | 229 + tests/leases/test_redis_lease_store.py | 298 + tests/leases/test_redis_reservation_store.py | 226 + tests/leases/test_reservation_store.py | 133 + tests/leases/test_wasm_credit_gate.py | 300 + tests/leases/test_wire_client.py | 131 + 33 files changed, 28523 insertions(+), 38 deletions(-) create mode 100644 conformance/README.md create mode 100644 conformance/SPEC.md create mode 100644 conformance/vectors/check-flow.json create mode 100644 conformance/vectors/crash-windows.json create mode 100644 conformance/vectors/expiry.json create mode 100644 conformance/vectors/lease-lifecycle.json create mode 100644 conformance/vectors/lease-manager.json create mode 100644 conformance/vectors/reservation-lifecycle.json create mode 100644 conformance/vectors/track-settle.json create mode 100644 src/schematic/leases/__init__.py create mode 100644 src/schematic/leases/check.py create mode 100644 src/schematic/leases/lease_manager.py create mode 100644 src/schematic/leases/lease_store.py create mode 100644 src/schematic/leases/redis_lease_store.py create mode 100644 src/schematic/leases/redis_reservation_store.py create mode 100644 src/schematic/leases/reservation_store.py create mode 100644 src/schematic/leases/track.py create mode 100644 src/schematic/leases/types.py create mode 100644 tests/conformance/test_vectors.py create mode 100644 tests/lease_support.py create mode 100644 tests/leases/__init__.py create mode 100644 tests/leases/conftest.py create mode 100644 tests/leases/test_check_and_track.py create mode 100644 tests/leases/test_crash_windows.py create mode 100644 tests/leases/test_lease_manager.py create mode 100644 tests/leases/test_lease_store.py create mode 100644 tests/leases/test_redis_lease_store.py create mode 100644 tests/leases/test_redis_reservation_store.py create mode 100644 tests/leases/test_reservation_store.py create mode 100644 tests/leases/test_wasm_credit_gate.py create mode 100644 tests/leases/test_wire_client.py diff --git a/.fern/replay.lock b/.fern/replay.lock index 1f4d4f3f..3aad15d5 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/conformance/README.md b/conformance/README.md new file mode 100644 index 00000000..0b0f4060 --- /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 00000000..6191f101 --- /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 00000000..eff99903 --- /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 00000000..c8ee05b9 --- /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 00000000..e1f7e2cc --- /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 00000000..085b5faa --- /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 00000000..79ab1afa --- /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 00000000..eea6b50e --- /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 00000000..1da71a09 --- /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 35852ec3..ce880ad5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -155,7 +155,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" @@ -180,7 +180,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 +191,6 @@ version = "4.15.1" 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.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101"}, {file = "anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94"}, @@ -251,7 +249,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"}, @@ -263,8 +260,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"}, @@ -276,8 +271,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"}, @@ -295,7 +288,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"}, @@ -304,6 +296,32 @@ 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" @@ -451,7 +469,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"}, @@ -463,7 +480,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"}, @@ -485,7 +501,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"}, @@ -498,7 +513,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.*)"] @@ -510,8 +525,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"}, @@ -542,12 +555,87 @@ 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.8.0" @@ -739,7 +827,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"}, @@ -793,7 +880,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"}, @@ -805,7 +891,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"}, @@ -817,7 +902,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"}, @@ -982,7 +1066,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"}, @@ -996,7 +1079,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" @@ -1004,7 +1087,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"}, @@ -1137,7 +1219,6 @@ version = "2.21.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9"}, {file = "pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c"}, @@ -1152,7 +1233,6 @@ version = "9.1.1" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"}, {file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"}, @@ -1176,7 +1256,6 @@ version = "1.4.0" description = "Pytest support for asyncio" optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1"}, {file = "pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42"}, @@ -1197,7 +1276,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"}, @@ -1218,7 +1296,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"}, @@ -1233,7 +1310,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"}, @@ -1261,20 +1337,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"}, @@ -1331,7 +1415,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"}, @@ -1343,7 +1426,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"}, @@ -1355,7 +1437,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"}, @@ -1540,6 +1621,6 @@ propcache = ">=0.2.1" aiohttp = ["aiohttp", "httpx-aiohttp"] [metadata] -lock-version = "2.1" +lock-version = "2.0" python-versions = "^3.10" content-hash = "4eaeff06fb8e558535aa3729ed6f99c2398eaaba665bcdf9c096bb08d9cb5fc6" diff --git a/src/schematic/leases/__init__.py b/src/schematic/leases/__init__.py new file mode 100644 index 00000000..6dcb0832 --- /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 00000000..5793cfa1 --- /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 00000000..141b93be --- /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 00000000..0742ff77 --- /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 00000000..0accf652 --- /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 00000000..be3c3e84 --- /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 00000000..121631d1 --- /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 00000000..76c68ab8 --- /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 00000000..4a9d8a89 --- /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 00000000..3c3693e1 --- /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 00000000..cb5a49fe --- /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 00000000..e69de29b diff --git a/tests/leases/conftest.py b/tests/leases/conftest.py new file mode 100644 index 00000000..09213037 --- /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 00000000..e7a7b555 --- /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 00000000..43f9face --- /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 00000000..a4c0dbfc --- /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 00000000..574a65dd --- /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 00000000..d184b553 --- /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 00000000..c6271a45 --- /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 00000000..10c5d2f8 --- /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 00000000..90ea574c --- /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 00000000..6f499794 --- /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} From e16f49bc34100137a377aa207f60298a14d0cdff Mon Sep 17 00:00:00 2001 From: Ben Papillon Date: Tue, 15 Sep 2026 16:11:15 -0700 Subject: [PATCH 3/5] chore(deps): restore poetry.lock dropped by fern regen The regen resolved the lockfile against Fern's stock pyproject, but pyproject.toml is fernignored and unchanged from main, so main's lockfile is the correct resolution. --- poetry.lock | 1109 ++++++++++++++++++++++++++------------------------- 1 file changed, 573 insertions(+), 536 deletions(-) diff --git a/poetry.lock b/poetry.lock index ce880ad5..59e6adf8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,13 +1,11 @@ -# This file is automatically @generated by Poetry 2.4.1 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" version = "2.7.1" description = "Happy Eyeballs for asyncio" -optional = true +optional = false python-versions = ">=3.10" -groups = ["main"] -markers = "extra == \"aiohttp\"" files = [ {file = "aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472"}, {file = "aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d"}, @@ -17,10 +15,8 @@ files = [ name = "aiohttp" version = "3.14.3" description = "Async http client/server framework (asyncio)" -optional = true +optional = false python-versions = ">=3.10" -groups = ["main"] -markers = "extra == \"aiohttp\"" 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"}, @@ -161,10 +157,8 @@ speedups = ["Brotli (>=1.2)", "aiodns (>=3.3.0)", "backports.zstd", "brotlicffi name = "aiosignal" version = "1.4.0" description = "aiosignal: a list of registered asynchronous callbacks" -optional = true +optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "extra == \"aiohttp\"" files = [ {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, @@ -187,13 +181,13 @@ files = [ [[package]] name = "anyio" -version = "4.15.1" +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.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101"}, - {file = "anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94"}, + {file = "anyio-4.15.0-py3-none-any.whl", hash = "sha256:7ecd9937369ffce8bba0b5ccb9b3a9507b101b0ed50256aecfbab27e6c2acb99"}, + {file = "anyio-4.15.0.tar.gz", hash = "sha256:b5c620ed540725e2579c31b17bb995b3bf02c9281c9cace04c7d186380bab85e"}, ] [package.dependencies] @@ -208,10 +202,8 @@ trio = ["trio (>=0.32.0)"] name = "async-timeout" version = "5.0.1" description = "Timeout context manager for asyncio programs" -optional = true +optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" and extra == \"aiohttp\"" 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"}, @@ -221,28 +213,13 @@ files = [ name = "attrs" version = "26.1.0" description = "Classes Without Boilerplate" -optional = true +optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "extra == \"aiohttp\"" 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 = "backports-asyncio-runner" -version = "1.2.0" -description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." -optional = false -python-versions = "<3.11,>=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, - {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, -] - [[package]] name = "certifi" version = "2026.7.22" @@ -326,10 +303,8 @@ vectorset = ["jsonpath-ng (>=1.6)", "numpy (>=2.4.0)"] name = "frozenlist" version = "1.8.0" description = "A list-like structure which implements collections.abc.MutableSequence" -optional = true +optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "extra == \"aiohttp\"" 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"}, @@ -540,7 +515,6 @@ version = "3.19" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4"}, {file = "idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15"}, @@ -638,184 +612,157 @@ files = [ [[package]] name = "multidict" -version = "6.8.0" +version = "6.7.1" description = "multidict implementation" -optional = true -python-versions = ">=3.10" -groups = ["main"] -markers = "extra == \"aiohttp\"" +optional = false +python-versions = ">=3.9" files = [ - {file = "multidict-6.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c0fe437a6d2f36aac2b49517057776575b5bf359df314cca20d230a6e139c089"}, - {file = "multidict-6.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f3a0a31189acf6703307397c6139ddabd734c20c5ef92649fc93e473df6615a3"}, - {file = "multidict-6.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c9c98d2f0126ba84cb45601eed97ff67ff767e19ae6eb3c31b02827b54d700e5"}, - {file = "multidict-6.8.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bc7003991ebd368a20d05228137a37b3d3066751f3ea1e4f7b8efe8e752f2f5"}, - {file = "multidict-6.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2c5fd0fd39574ccd58e1a52565b341aff522c5c836f1b3eb7605c371e61f52c"}, - {file = "multidict-6.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1cd4d66ce894a45482e1ac2837c31d0bd447df35065e542b60055aa2d00404b"}, - {file = "multidict-6.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a2573d0fd34f361a4a14e54d8cda3a91ac4e55fbf0d719698024f3b09c5b147"}, - {file = "multidict-6.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edda19aff836ec515caafc09ea53d2ab144a041f09ee9a7cefcbd3ae4e976256"}, - {file = "multidict-6.8.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e418ec99574ca24365ca96546af285c2b021a1a072478a79f0e3cc3b08837154"}, - {file = "multidict-6.8.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11d71490bf4bbff1141b14b93af419ad68c56b60bea9277fcb3f94dcca4796eb"}, - {file = "multidict-6.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e8e1e895e23818d343e4ae7dd95a0a556fdeaf8b471acf1c0a39b93c6f54d478"}, - {file = "multidict-6.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:15db8e6cab5f4cc9241bc56e69fdf3452cf49c10ee3c7977c742e68a275b3786"}, - {file = "multidict-6.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8dc2d9c3a924ed14166e63650b2cf9f59e7821743bdd50b23802bd97ca09bde5"}, - {file = "multidict-6.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f2fa3d3b1c933d4bcb8fd2018700d5e7235c52f2ab8c88d22286965c5c0f00f8"}, - {file = "multidict-6.8.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d6dc7804c50fabd28644d4d18a4b20aad3681b3e64f3acd3182b330ca73f7a32"}, - {file = "multidict-6.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0eca15d627e942ce186a935061f1568cc46c02e97c419c8da802df2be9f917d8"}, - {file = "multidict-6.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d7e5ba0a0153e35fbce9c51df530c8b4cb0c3012b46a04ff9a048441a269c2ed"}, - {file = "multidict-6.8.0-cp310-cp310-win32.whl", hash = "sha256:b7e62b8fc7bd6cad007b9f2e0ad9c8d4854c06350d5f51e1a439dd18b510ecac"}, - {file = "multidict-6.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:dc911ae6152e455b16a2a1a626aa6cd612fa01efb9d0a4ab3f5cf328b911483d"}, - {file = "multidict-6.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:6300d5176647145ba1e22991c924fb29743e54b4d7b8bc85a0d3ec0e55e189cb"}, - {file = "multidict-6.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b8986d4313dcee7c932837d16a535f1840b827bac1ea7c5c4c80751d0423794"}, - {file = "multidict-6.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2622fe114c0bd66ca5c461859357587f5a5e35ee5ff49fc5643d1bc78dbb41c6"}, - {file = "multidict-6.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26a7aafc992e78872e2c8c1f7248c0e01139cf9020a7781b0c064fa566832712"}, - {file = "multidict-6.8.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:05c2e90c5289c5f7436ba2c25812a5fbdaa1c1bc11c8d8d3bbf64f5cd7c633dd"}, - {file = "multidict-6.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7941ef106ca1f2c62314a13c7ed913bcf49641f3efdc12864d588e17870920ac"}, - {file = "multidict-6.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a5a7ee1217949ddd43c6b7bcf70d5c22193bb50e8c695386de5905325e93ce9f"}, - {file = "multidict-6.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b62b7e0025aa48dec11e125e655d1157985a5fdcec04b1ad500101ad072b891"}, - {file = "multidict-6.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea880d441be7c510106bc56064be39266d948aef94ad4955e8784690019a5d9f"}, - {file = "multidict-6.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab83fdd8cf307353edba9c427c17a3a021c2522d690f5633dd9f72d28b48ccca"}, - {file = "multidict-6.8.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3d1f48582686a0a3b81e9b43234766cc96697df72081af3f48107bd3f34d34e5"}, - {file = "multidict-6.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:563661919f603374c40cf45ffcd25535c12b8954203569a2ab1cee5265871cf4"}, - {file = "multidict-6.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2ba9933e8f35fe4a70f540b837254c4055da82dc3a9e500a8f95e61498083a15"}, - {file = "multidict-6.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:68d40b2bace413f3231f5729d3fcfb1837fd31c4907e241b5d43211bfd76f3c2"}, - {file = "multidict-6.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a9e246f67ac038568b854ed7c5578e4c6af1f742359901a8fcc3603ff1358df6"}, - {file = "multidict-6.8.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:29b6e7bc4442a56cf8e0dc1cabf3fdc77cd533568d6829fc76a1effd2ce332ec"}, - {file = "multidict-6.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:82780eb8bf59e8fb25dd081fde6e058805045d6374a7f2f877effc826ca4434b"}, - {file = "multidict-6.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2196ba6df392c3574acadd14ef87550f3611349c8618564de324b806a7a31cee"}, - {file = "multidict-6.8.0-cp311-cp311-win32.whl", hash = "sha256:b8b7aa75146266fd3e2a2437cf69ae188688c04ab8665b163d4257b46c1e0c83"}, - {file = "multidict-6.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:b03ca066b47b18b205cc080dca6f76cbd159f8cdd33a02a0700164c13b37e463"}, - {file = "multidict-6.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:54af1266710cb0f305127ae0b970aff8d208057f8a29cd6e1db99b0114947035"}, - {file = "multidict-6.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:22a310ad37672a261e55a8b5e28d0ae08cfb68abb1f46418ccd19835c3b8e836"}, - {file = "multidict-6.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bb8c7da8c861391f7ae48e3593762be2dabe405109e01aec520fbe1a6d15d14b"}, - {file = "multidict-6.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10456943903744ae1249728161c96bd9d2f7eb5ee17fcc2ffda2dc32e1bb36c7"}, - {file = "multidict-6.8.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:658f5a1895b804423d97b22d06fc0d0b171c7c01dcc3aa9c8faf0c0e26a249a5"}, - {file = "multidict-6.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c10b22860dbd09982d0b8993b66231a861bea2993d4a817ff35273f6ea285a"}, - {file = "multidict-6.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:088b04a66b3c1fce6fe4d771ec184a0426262d0b86709c908477b4ac7965df40"}, - {file = "multidict-6.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9caef53b20a105c0d66518a34be2f71b2783de8d091767575ef86f6ea422236d"}, - {file = "multidict-6.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a5e1583c14775580da05641240ce0d93f36ce3ddef3d5083a827468b0bcfe874"}, - {file = "multidict-6.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:003a3bddb32915c3f67096ea41d24e53edf710edb65a1f5d0c70ab40b0e4d20b"}, - {file = "multidict-6.8.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35977263d9bf506dbc65349f63b3b8c91606d4abc110990945e3b94bc671319c"}, - {file = "multidict-6.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9dc7b4ff6ef184504b49ef9a4113d49a646653b2ce89f5f48c1f57cdf6ba081"}, - {file = "multidict-6.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:887f9a975996032c686719eb7b3e1e7942fab5079c2b778bbd9afe9a9d78244f"}, - {file = "multidict-6.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f3071e6515cc63714d014da8f738ae9fa3997c476203f3cd46de380c2376ed7b"}, - {file = "multidict-6.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5f3a2af441670d80ce5fdf13b6c1b421fc1fc7fc5182d58ac7486738bb2b742"}, - {file = "multidict-6.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:31e8901637e20ccb3cf8f8848b5d0f7a00462bf5b34f7cf3dcbb2753b18e8b39"}, - {file = "multidict-6.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:13967dca8b2f33230a1427b52438326bb1c9101a1df22a3309ed3fcbbb3c96f0"}, - {file = "multidict-6.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad474c11d851b6fc97cb625e4822bc0cbd567fc07dc2602e28faec5a36b42bbb"}, - {file = "multidict-6.8.0-cp312-cp312-win32.whl", hash = "sha256:7bb0dad75068fee80fcb60f88569722c199d8656a16706702dc6e3b786819c90"}, - {file = "multidict-6.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:7d26dc8f070c0ec5579e987fa615ffd6883086106eefdff9e10d160fc5630630"}, - {file = "multidict-6.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:e6ec7d37841609a691b96a10b4fde386c7cd93ebbb939f59c9f23325ee788395"}, - {file = "multidict-6.8.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:ec0a4d066356054d569a66e0a94691a2058b680be5e710298f61db11a3c4609f"}, - {file = "multidict-6.8.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:714597cb5d5e15a8a449d2ae23c45b486a9e8fa33c462c7a33d7f35b65d92943"}, - {file = "multidict-6.8.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e0db3a4d1e264e225037a6023888972c25206a96e016021a5bea41c9a939f2a9"}, - {file = "multidict-6.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:27747162712e85c84598d364425dbf1714ff335bdb6ba3171c4e5081196e8916"}, - {file = "multidict-6.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:847d6082ae694dc95e548acb201bc100e1cfa96513bc71fdcb86f709dad6c435"}, - {file = "multidict-6.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:88a6df88567680504ae28bfa7a1f2f64243d91e79a40b2c92ef42efc531e23da"}, - {file = "multidict-6.8.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:560b211fc3bd4a1e1c6de44f6d38113bf5b410dfc89a4c0d2a3c0edbf1a0dfb8"}, - {file = "multidict-6.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202436df907c15adbb94360296c425ea53cf8968a5d2cff9b5b9790ae1972b33"}, - {file = "multidict-6.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c46a08bf070d6849fed483e9d9833f9d06aecb8382ed985be0b38508b3ae958e"}, - {file = "multidict-6.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cd560498ae8e1bcc955643c1d78eb8e338226d07a983c656ea8c4443d3eec0f"}, - {file = "multidict-6.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:758233648ac47b07c575224c4eadd73c8929c3b4c31e2afcfea935fde1cda735"}, - {file = "multidict-6.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:122adc7c46ac1e31ecfc7f81b2530533dccafdba70f5d741649f87e336c63384"}, - {file = "multidict-6.8.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8125e60f3c70e323ac07dd8b3635f7b3bbc5c3a9ac04ae5988f668ff7ae28a18"}, - {file = "multidict-6.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:83ff054b04915be5c15680da6c6012474a2cc2bf534129a0e8c6a99f17ba7238"}, - {file = "multidict-6.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:930c6058047410e3edff445f5a6e4457f2e089042dede00e2d18ce06f3ceae2e"}, - {file = "multidict-6.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:13e26f59f0eecfc5f67c663ad550ffdaf62c0f657547cde387f6c86af1c9449e"}, - {file = "multidict-6.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fd789a294d8e098528be29b2669b83005ce569339f8cef167fc0274c3115c34c"}, - {file = "multidict-6.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:3126f2a96704505aa4e92a72d6e8a5d7f29d40a987ced8bf69e29d71dfc71fbc"}, - {file = "multidict-6.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:23c9ee89967b6a9b4048acb3b93b660ed714ce9c8bf3bbe652959bc120dc02dc"}, - {file = "multidict-6.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a62e302fc8cd6aa8972207e7e951d1fdee7c1dda18568305041d19f0e2c00f5"}, - {file = "multidict-6.8.0-cp313-cp313-win32.whl", hash = "sha256:093167d22a8c95af30f597b8a5686f20a14512989942d4be804d119899caca20"}, - {file = "multidict-6.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:f25b61a708bd276e8cbb6afcbbf1b8e793a3be70ba0a842d0b8692020f83b706"}, - {file = "multidict-6.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb36381e1f9f9d06eba2f10bdd438e5d20c07d5b55e1a3eee30b9f44cbf52316"}, - {file = "multidict-6.8.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:f8b09b25e0f4dc2ea9e2adbb1cc3ba11a94d6fa3dd978ae659c8743052e1afbc"}, - {file = "multidict-6.8.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1f57c414be82490bc0e0305fdb834186229b2d9b6a35fa0afd1eb1a772d125ab"}, - {file = "multidict-6.8.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:00be37bde741bf60871082cd347a093218c44886e99231b7516671c70f2c280d"}, - {file = "multidict-6.8.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e37b744849fb631bb52e3dadde35ffeee365a6c41cf71257b5b7acc9cd83fd38"}, - {file = "multidict-6.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c2b2a96cf1dd99fe7867be4c013314225f4d5786e6685906e29932d42aca6f11"}, - {file = "multidict-6.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bea7df027015856ba5d0a88e3b4777ff8cb5c66b58fc108050fe79d4dd9d4d2d"}, - {file = "multidict-6.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d3da668e903c934ed0b587ecacfed6901f6ae6384a6e975887592b61845e78bc"}, - {file = "multidict-6.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64eaeda36ee8d88f9e8616a587a8c66a663283cf6e0dcf013c1ddd8c758e4aef"}, - {file = "multidict-6.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ac746cb365bac1c462da9e3e6ab8904a8efe2217a56b0b2e3d9480f41d2b2602"}, - {file = "multidict-6.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18f0e06360c3e451a3ab800355773c8d125a758238d780c800b0ee5e90ee903c"}, - {file = "multidict-6.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69708fecaa88bcb2341397b49fc95057a835b02a3670c551b37f95dd79e64e3a"}, - {file = "multidict-6.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9606f583e7acaf61e7b3f56074e14037b9af7cb194590edfc0114b3ae5931ff7"}, - {file = "multidict-6.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1f66fe6a021173d0d47968491791966b9f3e6d61115f2491744aa0c07a6e67af"}, - {file = "multidict-6.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:be007d1aee2cbd530347dcafedb400891a3b5f1bd7135f95cf5d5b330b5219ee"}, - {file = "multidict-6.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8457aff3c12a89a8e1c4674de5c777857fbc429f40fe117a3d29538547cbc364"}, - {file = "multidict-6.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:95c27b4f3f04320fc44e338573f40c5c956b504a7fcf081a157fd0b02579311c"}, - {file = "multidict-6.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d244cf6b52b5ba1c34c3832f4652a668ebb36d95949b96eed9a1c54d916a90dd"}, - {file = "multidict-6.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5bbbb696c8024475b1877d14ce20d5f1cc05b8f6d786cea0fe3aa7fedc02e891"}, - {file = "multidict-6.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:cbd86f9787c5e2f5fd27d8b21458222f107347c6731c4e93dde68f554b466a2d"}, - {file = "multidict-6.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2f8a4b0b4d639d525928c7f30de527bfdf9ead6e44a5e8cb9c50aced5e4590cb"}, - {file = "multidict-6.8.0-cp314-cp314-win32.whl", hash = "sha256:8890c89d662560e51c55ac1304d6f919b23942abe9ae1127cb1de9aa6132fa52"}, - {file = "multidict-6.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:45cc39ba50fb0754a4359b90f8229ae08598fe2266abe3521b4e5a9ba916534a"}, - {file = "multidict-6.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d0264f8d5cb0a803f650a6a8572dfa0cd1e099a2234c588dc8fb220b415b865f"}, - {file = "multidict-6.8.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1969971900b0871530f9b62280dcc2d75688e74d2a69262bc01faf2b96c78f04"}, - {file = "multidict-6.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8180b635290a75af8478f1b3e9810135381ae24833293fe77b85c1c21ff842ab"}, - {file = "multidict-6.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4261863fc8b5ab1b815ede94e592e94c6af5b04616014929057e61859e7382a9"}, - {file = "multidict-6.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0b143d53590e89f43153d81d505a8448d4d57354354385aef8a51d67ffefa27e"}, - {file = "multidict-6.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da1c112c5784ccd9d32cd90be6739fee32644e874eff6ae8f0497cba3e352e58"}, - {file = "multidict-6.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f7eefd0233a7c33ca980a5cfef26f1e9b5e2137839e752a99963696729f12d91"}, - {file = "multidict-6.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:348bb85e2038b40c007383616d73f734869063772372519549ebd7da1723d1a4"}, - {file = "multidict-6.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:095f62ea4e7a3be2f6c567ab695ce10e950f2adb905c1bec82281593e0b2d2ad"}, - {file = "multidict-6.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be569fff1d85cd29391c431c5641c8772acb75bbdc61e60a8e82fceb9023d385"}, - {file = "multidict-6.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3533a03e4e789baf6a286e7b0b1b6da3f3d7c3eab569686ee29ee1d8b52e2cb4"}, - {file = "multidict-6.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1bdb9b8fba5a9aef673ec90db3f55b1ce743f2fbdea4d37dc04d14ccdfc153ff"}, - {file = "multidict-6.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8d7b66c9e09c0bb0add2b5895e646b62a0849e71155066f215523de6b95cbe6"}, - {file = "multidict-6.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:563d6500ca80dac7bba6f48a78e0ffd87e21a7d4d24642c6503a2ddccd70c110"}, - {file = "multidict-6.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:346ac52e56bcda320c0dcdfdd081947ed7cada33afea4e2284bef7b0733bff9b"}, - {file = "multidict-6.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4ee953a5ebaeed38dc21cc032ed17a9d9782802e00042200497ab4b01b0bf7c0"}, - {file = "multidict-6.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2f79cc3e8039a8cf5c77e0811b0807953fd52d0863b9b76970b20d696dc64a78"}, - {file = "multidict-6.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43a4b56555bbcf8af161e7c7682bd93eec10f068c95844511864c018c8e5e13b"}, - {file = "multidict-6.8.0-cp314-cp314t-win32.whl", hash = "sha256:48ea524a25a1cd5972cf293bc95713918cba0bcd6fa9b992d906c857c546abe2"}, - {file = "multidict-6.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d0be2b832435001bc623ca7f1499ca1a853d4f082fb61221a80ce71132f50b26"}, - {file = "multidict-6.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:62b8e291a4f7edbf7cde7a43d831d893ba443a1b627498b53581943b0e348feb"}, - {file = "multidict-6.8.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:e192018b732f7b168e6604cbdf40fa8e05c996693b9eb445a0d8a73f4b77c5d3"}, - {file = "multidict-6.8.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b25426f9f6ed402835617c8f23609a47045f91ecff365eb6734817e039a8ed25"}, - {file = "multidict-6.8.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:fa6c2880709c84457de104385b704fc28860f27e442ad13966fc4af8e714fe9c"}, - {file = "multidict-6.8.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:eabb03dc3e4ed6333ecd1cc9826ec80e7a98b5506deeb832d7260c8e44166d23"}, - {file = "multidict-6.8.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:59e539c4eb4d3a53b0e630a6ba2b2f2824732b5e73f90e30a280f12fde157b15"}, - {file = "multidict-6.8.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:835d5a90b11d1f5f8200ff3cc8316bded76eebebc92436398947a27657e645e7"}, - {file = "multidict-6.8.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d2d236b8a44ae91536a12ebcb996bdb31cf27425f36b4d05c87f2ba2716050ba"}, - {file = "multidict-6.8.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb9a60b7faa5d37c426fa91cf4d6738182a1f2755b9fab7c9c64cd466c4ce51e"}, - {file = "multidict-6.8.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ef606c15cac6c90279acf34120784b6f36662cbf382defd3955cd8f1115336b"}, - {file = "multidict-6.8.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:68186a2d4051c8ffd17be33553bea2ec9bbc8ef860fe2980a221d96126296f31"}, - {file = "multidict-6.8.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2cc66abb85e2108c9ff8a1c0d20fa260bf690bbb33caef4ff3ecb2c2cbdfff5d"}, - {file = "multidict-6.8.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69b3e519a132bb943b0daae15fc8c2168706b17f826481d32a32a5e784b129e3"}, - {file = "multidict-6.8.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e41226ecf607f062fe34a2f4cf64ad3a89e3a0180dc800b463b6b14c06dd10dc"}, - {file = "multidict-6.8.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:610c7637bc36b90f39e6c66f710f93d57018f83d53e1e187caaa218c6892b95f"}, - {file = "multidict-6.8.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:65c85c79f5a2c04fbbc18f006c014674dc5fdf270cb978d8862c82c6f694e60c"}, - {file = "multidict-6.8.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:628ff11e6720f90acd0c305dfa3339f04a783a20de8cda6ac333ba46447261e8"}, - {file = "multidict-6.8.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0935971bffd0b479fc90c4811ca787703e93fcb6afea939a375dfc80285ab368"}, - {file = "multidict-6.8.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9442b14eec262a1f74369bbd07e75bc5155105164649a4b9fbc1ebc7b8fb0b14"}, - {file = "multidict-6.8.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:397599503b718f0137f26d3f6532d6955069cd2e5917c47ef581495bc2529ff8"}, - {file = "multidict-6.8.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9e37024b41d7a7e7e9cce14b248d54707c21c2a2ea30a47b71bdcefcafec00f2"}, - {file = "multidict-6.8.0-cp315-cp315-win32.whl", hash = "sha256:071da134651b04a8507dfb331ac0988f376337c2aea59486bf20989fb5b5a64e"}, - {file = "multidict-6.8.0-cp315-cp315-win_amd64.whl", hash = "sha256:3bafff8598f0528017ddc74194e5451d5c22d046c98935f8f86247b0f286e4f8"}, - {file = "multidict-6.8.0-cp315-cp315-win_arm64.whl", hash = "sha256:e886ef8c9879105fe4fc99417447b3a5f35d1131412ce839470bd2089fe2043f"}, - {file = "multidict-6.8.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:883284137e25318ed9735b742ae46341a864888fae28e8b6314c4f84da080f08"}, - {file = "multidict-6.8.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:ca52b9ec80851366197577154c862c4c4c7036ca76ae94cef5cb59c5cfeab944"}, - {file = "multidict-6.8.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:91fa75d0a693832106d98f66c849f034f21c828d14437f1fb97d3784aab89e84"}, - {file = "multidict-6.8.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:307c1acd812fe897e7fbe10c6758822e8c04be4e7c60a9f54901cdf8b5ab8bc3"}, - {file = "multidict-6.8.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf684986a2490628f059a99dd107b566a2d34cf947f8eb8387e0500a1f90c5"}, - {file = "multidict-6.8.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:77745725125d01fd613b6db043362aa7c6bfbfdb23d45dbfc3d92bf58160af62"}, - {file = "multidict-6.8.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8daafaa0b2eb43f76898ced78b1e0fb91b38c4fa50da516c18067f2a2d578c20"}, - {file = "multidict-6.8.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c68e0c0649d17c2d0339e3674e86a4aeba4a7e6b21c1e394cf947a95433b31d0"}, - {file = "multidict-6.8.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8a5ac357ac283490a8d1899b0383355fd1f8634b14ba0d59e4c0dd97db85556"}, - {file = "multidict-6.8.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:46029e6e27a3ec0dc55b53f58df82d10f04c5e111f78248279b530bedad2c30a"}, - {file = "multidict-6.8.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8d1046b5427dcafe6e8a0e07527dd74f1ee694006160162f53f3a17f15aad3b4"}, - {file = "multidict-6.8.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:f1f4a220db6ed7c8fd16b6d644ffd1f082651693204daf3275e049fadc849e39"}, - {file = "multidict-6.8.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:029897732a9c798737457e382bf84e8c64237eff224a90aea2639f4413c45e4e"}, - {file = "multidict-6.8.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:29be9fd289e9ab8f480996ea2f686e1654b80242033843cb11691688329423f1"}, - {file = "multidict-6.8.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:29631224698de1e42abc8fa7658d830e0aed0029785144b5832b695da5adef2f"}, - {file = "multidict-6.8.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:962f18c59a000f30b084ea2e6b8001521bb315efd4e5f10acf9fb36f366b7882"}, - {file = "multidict-6.8.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c60e50bc5b07faac92fd3a20fa21cc8cf3e3f7204d2867b206c73293ebc19101"}, - {file = "multidict-6.8.0-cp315-cp315t-win32.whl", hash = "sha256:fc5460940f50dff00731b4132366840ba9685286ea88ea104b661899084f3fea"}, - {file = "multidict-6.8.0-cp315-cp315t-win_amd64.whl", hash = "sha256:b367c342327717d644db4c0ddb37ceb655c84822215ea0773a3a36911b74b71d"}, - {file = "multidict-6.8.0-cp315-cp315t-win_arm64.whl", hash = "sha256:0c1c4debad7337627b86837abdf0237ca3cb3d7e17de7eab0177c263878546d4"}, - {file = "multidict-6.8.0-py3-none-any.whl", hash = "sha256:75daa15ca16d6285eb2e104b2f05ee6f8d9836c68da3ce5c85f615a0450eed0e"}, - {file = "multidict-6.8.0.tar.gz", hash = "sha256:5cd4637ce76312ba1e05eb9c5193fec231f64fee0944e135fa1e951242355b37"}, + {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] @@ -913,151 +860,132 @@ testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "propcache" -version = "0.5.3" +version = "0.5.2" description = "Accelerated property cache" -optional = true +optional = false python-versions = ">=3.10" -groups = ["main"] -markers = "extra == \"aiohttp\"" files = [ - {file = "propcache-0.5.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e443bc079b57cb231ed45a8a55879a868bf56449ca7a719e58988375f828d8d"}, - {file = "propcache-0.5.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3ecda25c86ee7500b5348cb1e8831ee4324daa45ec7e24265808121537c85bbe"}, - {file = "propcache-0.5.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:31239c475a43568c0d64d5e10208f449e004ff4894458507fed5ce53403ac3a2"}, - {file = "propcache-0.5.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5867f7d672f239fe70569c947a4453ed44f82c166ff08d4abf6cf7350d9a4b12"}, - {file = "propcache-0.5.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:544f08889abce275a77b27add29cdaa87a36e6ce6abeffcb4e87b4e29c577711"}, - {file = "propcache-0.5.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:58515aacf6ba8ad47a0b072ff2a27ccb39d7691ed9101636e6b5e151758b11c0"}, - {file = "propcache-0.5.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e61294a7d9b07a79b3c670dbceaa4aca37542e68cdf9411e04ed81cabb2f62a5"}, - {file = "propcache-0.5.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1956d7e98f2150e4b0aae71dccf61fb5628208c63d57e2dafae68301fe8e1a36"}, - {file = "propcache-0.5.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:79af5bf10e5a631ba601c33f379e37e64f036c55e60a99254ec55ab4678d268b"}, - {file = "propcache-0.5.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:400db553b2a2af6ea41ff3b6d5972fe493180fd519c1d001c9581fd446bdc150"}, - {file = "propcache-0.5.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0275013737080d5d113be6927ca6055a3e393a9d95f81667c4bdb927915aac2a"}, - {file = "propcache-0.5.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c2fd56f3b358f07e4fefcf54942f20f64e905db63dff8fddf0c67730c748b3d1"}, - {file = "propcache-0.5.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8e507119af1a2d417d75ebe202bc0a2a282ad0a3e6cf38f447a6cad0a2c5263e"}, - {file = "propcache-0.5.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d8a41e6f549e8bc13dc7b4987f5e96097bc2fefc66b682b3947a8e959f75a9c7"}, - {file = "propcache-0.5.3-cp310-cp310-win32.whl", hash = "sha256:e8e6acf498ee3d0a5c2a8468e23af7acb017c5b219b29e302af2636818d3830e"}, - {file = "propcache-0.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:09513990e2f33c9de838f0ab11b62660b3ea225000945d10143007e51539088c"}, - {file = "propcache-0.5.3-cp310-cp310-win_arm64.whl", hash = "sha256:5964c7e2f632facc552ab0e163cac05e1ce42acba20b4b24d445139629ed7625"}, - {file = "propcache-0.5.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b7edbbf2d9ba5829663dbc83cdb6e2b13bc80cd36f0bf185419dcbbb4930af7e"}, - {file = "propcache-0.5.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d2c6ccfcc810ac7f8782593934fa2215669a401ef43fcae5a8cab80ce4754a46"}, - {file = "propcache-0.5.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2f1723f7a060a20eb1f9971fa69c9931b5ebd253bef8baaa48a1cbd1c01c7bb7"}, - {file = "propcache-0.5.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba1ae9d55025434790882c2f1be727e725ccef83876a112af52247c7ed7ce321"}, - {file = "propcache-0.5.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30f9d52fdd02a216601f588e5659dd39cb7d2a0ef59935d33c5077718201f8da"}, - {file = "propcache-0.5.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0fdd72f694faf2b34907484b53e5df7ae6b5dad6a6e03564c6b872ccebb80a7d"}, - {file = "propcache-0.5.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:35de8d67f6b6a3830f845ee7cd3ae47189d7dd66088aff9dfd1fe50f5bda5a60"}, - {file = "propcache-0.5.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:377011845a2b1a8b11ab2ca33547aea1320d6f70c015bafe26486f0882dc49fb"}, - {file = "propcache-0.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2537fa4f56c12fa897f9371e4e5910417aa49ff916a7dc549a22061b46d33355"}, - {file = "propcache-0.5.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3eb545ffc0db5c86e83ba1aec3616efbaac9c22f05daff095005472874c12e91"}, - {file = "propcache-0.5.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:023d4e58beeb0865cfc62e2e14bf4ed30e21dfc20ce16ac24fb05516a09e1d18"}, - {file = "propcache-0.5.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:d693b78ecb4df14b3aa636d9c2a40e5ab3b4f81a3bb569fddc2b28299f1ff0d1"}, - {file = "propcache-0.5.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a5ea7cf6d6a0b67a64e6744219265bc5406d827ca3b46ae2d748c3aaa1d5e1d2"}, - {file = "propcache-0.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:66be2cef41263d823517ee617d19fd105a70ff58a0776e5e47cfde1196bfa2f2"}, - {file = "propcache-0.5.3-cp311-cp311-win32.whl", hash = "sha256:d8eed2e84204b4402407a5d1ca124cb653b9a6ba2fc01ce1ef1071a75ac9e176"}, - {file = "propcache-0.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:e01abf74acc057c0ef8533d5688a2bed25923cf05c450a0d82229fa538cf83c1"}, - {file = "propcache-0.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:ba7220f33a3df68245b09bc519e2c3f9f5c4123ea68f007c11529ecac8615200"}, - {file = "propcache-0.5.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eeb4e5a10149960ed021b8e35fa22440e106db6322a9f5249ad8992f38855ceb"}, - {file = "propcache-0.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b09edf2908fa5f0ba84fdb9c1bc6f346945b51541013ac29b12d20218f213279"}, - {file = "propcache-0.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2db81e15c4e0171ad26845b67a1e295ba446e717e47fefaf47f22ae0b2eca71e"}, - {file = "propcache-0.5.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50079e8be4f1bc33e8265f35fbe6449017901db626bc6b735181568c4bfb5e73"}, - {file = "propcache-0.5.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8ea844a859c3060a44485c4e842df3ead69ca98ca5550594757aac11dfef30d"}, - {file = "propcache-0.5.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ec76f86ec2de069358d896c468be3c1cb3b5ed049e3cec3ecb3015806313606"}, - {file = "propcache-0.5.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a36f13d838accaa887f9e54af4866ae344a51eb59b99dbd6cfeee701fc98ff"}, - {file = "propcache-0.5.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1a536910fbd2f4ea76acd6b1b50708ce602a27673ef5e5b2c8ba373c2f5127cf"}, - {file = "propcache-0.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f95a390defc963a5543012685b57eb9cd47078f50c4a3bb5c5cd7719c4175112"}, - {file = "propcache-0.5.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1d617a68a5f383855af3eec608ddd98dd3b964184011f20755a4e34acb194a5d"}, - {file = "propcache-0.5.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:28afdbaf9f1bf34bc719e0381a4699705990c59f412e43ff25f91a01ff6676af"}, - {file = "propcache-0.5.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:014cec8c711df6f22e0a50cfcb3e98d3d659856daa75e9dc2f1e206204027519"}, - {file = "propcache-0.5.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ae72c39267ffc08bab9239fbdef5fb8bb0fc410f141b1ed283f0dd349b8bcdb0"}, - {file = "propcache-0.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:22e8849a90d46d920469eacc52cf28dbfce9d9dc7fb0ee36ba3aa4b117fc8311"}, - {file = "propcache-0.5.3-cp312-cp312-win32.whl", hash = "sha256:71dbe01ca877ca1c7d61f0f83b71f2c994554ef52983fe7c6400d9c483c477cb"}, - {file = "propcache-0.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:2367fbfaf980a0ac082c64b53ae669ee704515134a28cd8c680642bd90a2ddd5"}, - {file = "propcache-0.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:646d9d567fdc8f1f8ab3e785f8702175a9212ea3432af2506f2f914f21c3ae16"}, - {file = "propcache-0.5.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ef59cf75b4d63fed6308d933db810b05f0738925b1470b6caad88afed597c7b1"}, - {file = "propcache-0.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9f10fce5b91adc67a566817365381279b6f4f3b19fbb58eacd2ab8d045e75492"}, - {file = "propcache-0.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bc469f68cc978501b6ec69b49f67738ae099064002d0bea283c491d086e93a27"}, - {file = "propcache-0.5.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f683f8eac4e695c48a3e24a7347b392322219b6a99351d7b6bf70c9ef2262126"}, - {file = "propcache-0.5.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9fc1d60bc45efd3b1440a17e4a7eea268d34c22eb30ccec10246b5b6833a603c"}, - {file = "propcache-0.5.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:384ae10f8d6595595361566f9073578c131b1eb7c651bce5d78c2c0d411a221f"}, - {file = "propcache-0.5.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f18065f005006f20fde9c08d21b375d52908b72c493e9b2531dd25da89a8ef50"}, - {file = "propcache-0.5.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2c73e4520974b55081770703be7066d3b346db16abe399148a154be0f9f92c81"}, - {file = "propcache-0.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0e55ddb8404887d1bffb0937287cc27c98a0f1a4b91453b21b9d5c9df0743377"}, - {file = "propcache-0.5.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5b90847530d99dfc9ab31a6b4432fe92ad9868630b45acac21bb08468ea82614"}, - {file = "propcache-0.5.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c71593d15b601f785e4f0362822d3101b999e4a696210281e83e035abb8c33ba"}, - {file = "propcache-0.5.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d3f6e1a3a0efc4ea71b8dd4310c4c5693686ad0c3b4c657beafea3aaf6a28428"}, - {file = "propcache-0.5.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:45115e5e32fcc253de3f14633ca1c982239c9ba37ba41ca45a7e30b3afeb3b77"}, - {file = "propcache-0.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d7d377c2e8ddd7b7254c3ec6b23275bfb9d307a32bef017ba7dc94b94f45c509"}, - {file = "propcache-0.5.3-cp313-cp313-win32.whl", hash = "sha256:442d4a18df8be3ceff91bcd8eb850c263f36726287327499c68e72482ac07b48"}, - {file = "propcache-0.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:a8e2d2bb4285a5fa8e5c01465b5379abf8f244d4978855558432987ef62672f1"}, - {file = "propcache-0.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:82ff2cf7bcb0c207ad1d207e1a51ea1d53aff47628729c7368c5ead6924d3b7f"}, - {file = "propcache-0.5.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7ee15901025c40b58ee5547a7c244841701c09758f3e98ddbb0b876d96d52ad4"}, - {file = "propcache-0.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:37e41cd6c1a27ddbbd0b556fb0c02ae8e55c41c8a122c83f1350a017f0bbc69c"}, - {file = "propcache-0.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:04e21d1eea62765ee0306c80c61937255a5cd99c9c56d6cb6100ca5794e8b2f3"}, - {file = "propcache-0.5.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f698081e39d0a63f44ae35da7c43c595f9f60d26ee08e575576fd7df3029e31b"}, - {file = "propcache-0.5.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1a8588f64d4d5d6e65d7faa9e0d1dce9fdb25e50aca194cffc6106deaf9d320a"}, - {file = "propcache-0.5.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69f226874a50eb2fdd0db47303b66b1084a545aa1d68d45ce2a1c9920cf2ea00"}, - {file = "propcache-0.5.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfa60b1dbd3c7881730407df60d2be83058ed145701b5c51ff113b7a3ced2224"}, - {file = "propcache-0.5.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a78a241677f693485ee6c0165ab657341e7ecac93f8d53cd70771ed6f3eafb61"}, - {file = "propcache-0.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c5930611bf42350905ace62fc8306146cb644c5d43efba541e68f4d22383e459"}, - {file = "propcache-0.5.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a7f485b78c7ab8e8b9c3375d32d25a077d530cfc676c665e89685fccfa2ecbbb"}, - {file = "propcache-0.5.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1162ba1e960ea6dd0453ff3ae29328a37307486c260c93343782ea1d56eca6f2"}, - {file = "propcache-0.5.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:49f3c98e977785ea49a8b99463cd81f343c8307dc98fe485fb94288597d8c01a"}, - {file = "propcache-0.5.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8ef15c7a41364b098c26458dc3b57b7a5db6e042661c5cbeb75ed78e503162cb"}, - {file = "propcache-0.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:54f06f17c66add28dc79e314e3b2ba7a9612564314d8b2c27b2a8c2f270d33c7"}, - {file = "propcache-0.5.3-cp314-cp314-win32.whl", hash = "sha256:37a99cf741523fa5406ef2c60925b7ad616afd772fb3364b431a921404853d46"}, - {file = "propcache-0.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:10573db5b40f55728e0abd5fc47096db6162ff612af10430ae93377af09e2a4b"}, - {file = "propcache-0.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:9e561c183c72665752cc2ede30d985a9e80534f43aaa16fa6e1224d151ad54c2"}, - {file = "propcache-0.5.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:622ae786b1e3d46684b8866cd7415c9eca26f341757c4bfdbdfad8ee129a7103"}, - {file = "propcache-0.5.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a3af1190a9c62a0da8d90a0a613d6b69029e86ee60b599e146c1e79a80c77d0"}, - {file = "propcache-0.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dcc70083abd3374d7ec338c6dfd37030bc1e54ed2c71abe2c51f16c3d7af5531"}, - {file = "propcache-0.5.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:665451eaeb3e5e09129c4a789d63620502f50c16be25ebe9370f7dc05ed4d49b"}, - {file = "propcache-0.5.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8c8e8b2dfe7d7bbca58b12170cd65b4f7ec701e67812a853b7da8023f681c9f"}, - {file = "propcache-0.5.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b6239f0d6175da9d07084c74628af8baa258fe8929faf6a75ce425b0db6ec9f"}, - {file = "propcache-0.5.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:104e8d289159297822541566d60769b81af3bfac971509a91c8b94cb2554d7dc"}, - {file = "propcache-0.5.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50eea49bd9c874378a015e8363befc05b3545a0bb1312ed44ba895cd7c5c9b3a"}, - {file = "propcache-0.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0f0e69dcc0aa9bd4aa22c9cf3f52cd92ed2fd067f2df93e56dc1d1c98d0538e5"}, - {file = "propcache-0.5.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:9498e6b074f7e4c58fa518e8f87944f87e612ad910bedaabfdc07695d91efa8b"}, - {file = "propcache-0.5.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b3541fae8294f9129e3ea393d146f28c035acf9106ee2025121dbd2b7bb2516e"}, - {file = "propcache-0.5.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:105ad7d80740aa4d44f025a7f7335599e0b26faa4d6c80918069c16e108d29db"}, - {file = "propcache-0.5.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9851f8a7bd86a455ba5f32652b13dae69011d93e44d3433fed7eb54b10f3d9e2"}, - {file = "propcache-0.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3745e60d95e52a782b1e99ecccc72d22ee61c86c1e368647fff0444b9ec4bb70"}, - {file = "propcache-0.5.3-cp314-cp314t-win32.whl", hash = "sha256:ec2e387ce38d9b73054a7e96d70d22c8471a83df6205019442394b079d26a84d"}, - {file = "propcache-0.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:8a3396b1822f0c5291a11bdf93877d3118d5cafca5905e185582beee039fa508"}, - {file = "propcache-0.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:0dd0ef45b87c5a59970b42dd70493b3c022be490a2268dfd391cdacdfb9a611b"}, - {file = "propcache-0.5.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:888624d71be1029609ad7f333be31b0fa3546caef67a5b17ca2fc7f5e07c8168"}, - {file = "propcache-0.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fcf51bbb689ee4c15226ab80c96c1d7804cb1f5fdbbc828b7f75f6f6ee5a172a"}, - {file = "propcache-0.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:73b3e3cf2b6b6af3f407439b647c371452481d53b786b75e1fd0956e1d69b59f"}, - {file = "propcache-0.5.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23788073f16c2e0706f5aeaae8489ba504027cc15b22f78bdd55df8e6b5f5b37"}, - {file = "propcache-0.5.3-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb98bd17ad50a99f9f6962214608afdb92a0ebef681a5f4a5d38024578176df0"}, - {file = "propcache-0.5.3-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c95b48cda71997707271ec146e13450c8a27d57ee5d01622a0afb88a0f5a6b95"}, - {file = "propcache-0.5.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50750f402638c9770e33e7e9ff8aa1d5115de8ea07ec29898c85abfa47b0f442"}, - {file = "propcache-0.5.3-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0506edf85bec8997f5084584f2b863a27528147c59aab9bdaca2cbee0e22bb76"}, - {file = "propcache-0.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e9fa0ec1edaa4a9a95d8e0ba4676f43f187323cc5249d8217b3d75484d922f77"}, - {file = "propcache-0.5.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:1003b8557b64ce8c09f4c0193be582231805997d6e74ca0e9c79eb7b375789b3"}, - {file = "propcache-0.5.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:282cb412e862cfc23ab796addeeaeefe9ac85061349bf474249f7d77528135dd"}, - {file = "propcache-0.5.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b6d618b5144fcd65ac04113cee9196525dc10c23c085f27f3c2c13de439d4853"}, - {file = "propcache-0.5.3-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:3c996ebbee2460fe77be155dd42ad4506fbcefed7a4b9b776b3894b0f52c1479"}, - {file = "propcache-0.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:95783c8fd4ec500c6afdcae87e9bb80c7379ef4dee5fcdf27c88e08f0c19621a"}, - {file = "propcache-0.5.3-cp315-cp315-win32.whl", hash = "sha256:55dca8dd42e5c12d9b7949a50df9a283e9720d26899f6ee53208f1919664812c"}, - {file = "propcache-0.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:f15dd057a45bcf50a000d515ca5263d567c18ebe7de85c71687fe2d88fb0dde3"}, - {file = "propcache-0.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:07478d53a45cd33a9c721cde42a1074dc7fed8fc5562bcfd83172d5b1f140ee8"}, - {file = "propcache-0.5.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:18ae303e7ae34fb2fdc21d190ffcdf025b8907e50da5393ff271b66fb08eb123"}, - {file = "propcache-0.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:fd0c772d8f7d0f0cbb73271c64f658622d7c143c5696462c918f73f3a7542c2a"}, - {file = "propcache-0.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1864f5637f61cf3fb21d5351d494de0b896f9867c8b4f000b8301c7468c6a05b"}, - {file = "propcache-0.5.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40cf37a31664e280fdbb6f8ea9ee5a16bcbe504c464f210d90f2e8cfac4c570c"}, - {file = "propcache-0.5.3-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8f0583da2556ac23e8eec959c45c608d7b5dce3c5e7637a81af695355a57b6c"}, - {file = "propcache-0.5.3-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f49d1c07dea7235efc959b72ceb592cdeb44b0f60ce69828af318c40ada3503c"}, - {file = "propcache-0.5.3-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:67af78cf66472e82c8beb672930f6279d08c0007ba4cb675a70cee5cff1fac88"}, - {file = "propcache-0.5.3-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc601955fe451749386dc4d7f3e7f8f1d4db6846c8be4582c5ee21ee0acc7559"}, - {file = "propcache-0.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:bf82eea5664b0b1323d2304c68e84215a5b3a11bd9cc733df09edf7fbae7fcbb"}, - {file = "propcache-0.5.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:d9eb3b83d2c106e0a1bcc788dc4d375cf60d4f1a4bfb151858fb84d4baf184d8"}, - {file = "propcache-0.5.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:b8149edcd93da046f6f1e3dad1df51c6257bef13f8171aec5b8aebbcba840f2e"}, - {file = "propcache-0.5.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:02729b64fea4ae4d9a5a9a94ba56f360978b1538bd347a81df19c3e5520d1ecb"}, - {file = "propcache-0.5.3-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:98bf605793ff20856a4bbc8af2e5ddbbf6cfefdf6b5cdc01587627a3e7057004"}, - {file = "propcache-0.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dc88c7b971999389dc5ceea90d8f346fb360888549e123644fc63b90dbd7f64f"}, - {file = "propcache-0.5.3-cp315-cp315t-win32.whl", hash = "sha256:1102c4f4390e6a50f1960b8cb494007f08f5a139b39fd278925330e622b03ab8"}, - {file = "propcache-0.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:09b5c52fec3f50ced0c5c810f02c1237d7c89a8f3f5345ad92c6a49faeafe6a9"}, - {file = "propcache-0.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:74f50a14ccc90bf5e544521783aafaf8081a21eeb24fbcb295ecb79d766a140e"}, - {file = "propcache-0.5.3-py3-none-any.whl", hash = "sha256:abcef8e75a2c324dabd46c842c17cb005ae968596a567e43dfd6ee074f9f907b"}, - {file = "propcache-0.5.3.tar.gz", hash = "sha256:5f283fde8fd8b1944fd1a1bd11db759b55c5f190795668d5187d3e3b1165d98b"}, + {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]] @@ -1214,60 +1142,60 @@ files = [ typing-extensions = ">=4.14.1" [[package]] -name = "pygments" -version = "2.21.0" -description = "Pygments is a syntax highlighting package written in Python." +name = "pyjwt" +version = "2.13.0" +description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" files = [ - {file = "pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9"}, - {file = "pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c"}, + {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] -windows-terminal = ["colorama (>=0.4.6)"] +crypto = ["cryptography (>=3.4.0)"] [[package]] name = "pytest" -version = "9.1.1" +version = "7.4.4" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.10" +python-versions = ">=3.7" files = [ - {file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"}, - {file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"}, + {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 = ">=0.4", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} -iniconfig = ">=1.0.1" -packaging = ">=22" -pluggy = ">=1.5,<2" -pygments = ">=2.7.2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} +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] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" -version = "1.4.0" +version = "0.23.8" description = "Pytest support for asyncio" optional = false -python-versions = ">=3.10" +python-versions = ">=3.8" files = [ - {file = "pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1"}, - {file = "pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42"}, + {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] -backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} -pytest = ">=8.4,<10" -typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} +pytest = ">=7.0.0,<9" [package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)", "sphinx-tabs (>=3.5)"] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] @@ -1304,6 +1232,25 @@ files = [ [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" @@ -1446,170 +1393,258 @@ files = [ typing-extensions = ">=4.15.0" [[package]] -name = "urllib3" -version = "2.8.0" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.10" -groups = ["dev"] +name = "wasmtime" +version = "48.0.0" +description = "A WebAssembly runtime powered by Wasmtime" +optional = true +python-versions = ">=3.9" files = [ - {file = "urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3"}, - {file = "urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63"}, + {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] -brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] +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.25.1" +version = "1.24.5" description = "Yet another URL library" -optional = true +optional = false python-versions = ">=3.10" -groups = ["main"] -markers = "extra == \"aiohttp\"" files = [ - {file = "yarl-1.25.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:142c06c4d6a35ee3ec5da08499805e879cb3ca7c1fbfbecb0140fe72403818d6"}, - {file = "yarl-1.25.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:24ce942011a61953e7d313438038f4d32ff21387b775f58a957f7a07dd55ef95"}, - {file = "yarl-1.25.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e23c82b63cd7652fc24d33ed6cc17099d607aa3b4fc4ddc75e95062f3d82df4"}, - {file = "yarl-1.25.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee202350cf57abf0e9502a41601841019c25d3db7ff52d980aaf31446254059"}, - {file = "yarl-1.25.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5df89f769cc8ff94c3d7e7603386fba309d25ce5240132d26c15baa8d0e96c4c"}, - {file = "yarl-1.25.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:83e9f4a25085bd4b7214701a0794ff1f50fc633ffb8bdfebf07abdd81c2db126"}, - {file = "yarl-1.25.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e636b64d24fd9c38053c5e389a1174c66361fa49dcfd220f4dd35b4abde7cb89"}, - {file = "yarl-1.25.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e5637ca8d0bd7fb72648a6c7934af4baaccb697657f7438c9d264fc2abb8b0b1"}, - {file = "yarl-1.25.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:683e362b8ba453080f7489c66f4ea794e751c35b72e7eab3575ef784c2fbc7fb"}, - {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:df23df54b5114a17c2d0ef192433e2e5a9f0c5178c32375e90b7cfc965f349d0"}, - {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f53dcd26694f148f738edc052b5a69234833e739f10f4c3287bdfd8ec0f7b326"}, - {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b8075fe90bc08e40b8b8a1874fab42ee4c7b56af05c5886e9cc841397f916908"}, - {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a8c2b841478068440d8b733005d13a5ef535b9928cbc05f17182d410f32ba449"}, - {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ca32926d7d77bcc8838425c4c95e040a3ace1cb7dfdae599013458dcda2607ca"}, - {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:192a866877a49993949ef1975864ad8728bea28ee810f6abe1a0729c2b500426"}, - {file = "yarl-1.25.1-cp310-cp310-win_amd64.whl", hash = "sha256:3f4d48a6112712973e676bd792121fee470e432d749177162d9949d5c9460a1b"}, - {file = "yarl-1.25.1-cp310-cp310-win_arm64.whl", hash = "sha256:48796ea00a303961507dc6c8437c4b325a6fc3f95f7c36c71b91ea9a8150963c"}, - {file = "yarl-1.25.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d693bf4bf534e9ba3ae2780cfd577f5135629f7b5ac653490859d0b77864865"}, - {file = "yarl-1.25.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab2054c5531af2a9ba7b69b8ec91e4f884420e83a8c5e579b013084cb57e5e5d"}, - {file = "yarl-1.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:564fdc7085d2245ab84f88882fdb1d6ac0723124bff6ded35bfb1c00f812630d"}, - {file = "yarl-1.25.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acae6b45d1ace09b6ba3876da43b88366ef368f73b988c7f57e14231753d4420"}, - {file = "yarl-1.25.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fb2a01ba8cd9c5d2c5dc1ec35e0fc951d04b4f037541d4ac090c993ce58b3d7"}, - {file = "yarl-1.25.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e92b6bcc741b86d67606c40d3cb9c7cc8e6c737f81e31f4a94efc204456c92e3"}, - {file = "yarl-1.25.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72c34ac7ad4314c19362d5ce27626dcc8429bd30bbf8c179f4234078851f9492"}, - {file = "yarl-1.25.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5add7b4ca7afeea91d52e4d4e4db3b1fe9885b71f07054560d8c4296b7441a2"}, - {file = "yarl-1.25.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:def538065f9e4d4cf1ae164bd59aba00dfa84f03923e0de4c3788f252d6bcd17"}, - {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de"}, - {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:71f42c5b9a948c113bbdebfa544598321431d064ff959d32e99b1feb61d68345"}, - {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:72849d892954be4d09e569b8b831ac39ce58417fedc767d4308a0fe542018a40"}, - {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:efb01a106f971cb3752856bca2318bbdf7f01bd8823779c461586cbe5ffd5258"}, - {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a1daf47cd95a7c3a63456336bc5aaa8c86dd3a47d07ed3d0e76132ae4666a5a1"}, - {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9489e6abf47ba37f332075a91444c7cfedb03e6ce99fbb2f116bfe1ce810da3b"}, - {file = "yarl-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7306dee25b8a0e737363f347362b875094b4dc4e367311470656ae420fdbf8e"}, - {file = "yarl-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:abb1384477f5901d436b5d2e5465954de46ea6098f59163d243660b5c4461d35"}, - {file = "yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4"}, - {file = "yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d"}, - {file = "yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6"}, - {file = "yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58"}, - {file = "yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad"}, - {file = "yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40"}, - {file = "yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c"}, - {file = "yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d"}, - {file = "yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54"}, - {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f"}, - {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc"}, - {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d"}, - {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1"}, - {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68"}, - {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707"}, - {file = "yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03"}, - {file = "yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85"}, - {file = "yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5"}, - {file = "yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26"}, - {file = "yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec"}, - {file = "yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc"}, - {file = "yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184"}, - {file = "yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651"}, - {file = "yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df"}, - {file = "yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab"}, - {file = "yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3"}, - {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a"}, - {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b"}, - {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a"}, - {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a"}, - {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98"}, - {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa"}, - {file = "yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9"}, - {file = "yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe"}, - {file = "yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e"}, - {file = "yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f"}, - {file = "yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac"}, - {file = "yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c"}, - {file = "yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c"}, - {file = "yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743"}, - {file = "yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f"}, - {file = "yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57"}, - {file = "yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453"}, - {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc"}, - {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f"}, - {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8"}, - {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868"}, - {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62"}, - {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233"}, - {file = "yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b"}, - {file = "yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414"}, - {file = "yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa"}, - {file = "yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287"}, - {file = "yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94"}, - {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9"}, - {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0"}, - {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa"}, - {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b"}, - {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6"}, - {file = "yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee"}, - {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0"}, - {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7"}, - {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b"}, - {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d"}, - {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3"}, - {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3"}, - {file = "yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371"}, - {file = "yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6"}, - {file = "yarl-1.25.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b1bdaae98bc016825dd3c9d8ee1832f829b3341f9cc6ebd1a1b0a7fef7367cc"}, - {file = "yarl-1.25.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e7011b8fb8c4054bf0c12e5edc6cd83778b0028e99ce59b18586ed036f92cfdc"}, - {file = "yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b"}, - {file = "yarl-1.25.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d42e7e3ca399555578b4d617e3a6ecf13371b3743a115995fa010c7bf341459"}, - {file = "yarl-1.25.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa4ed3dd308548f9e707d9caaf005d2d7f8c1e7868f858dfeb47fe76e16b391d"}, - {file = "yarl-1.25.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42a66563d8cc056ee32e6191e05097a7b2b3bc302e0bc3133daf8710eb18bd26"}, - {file = "yarl-1.25.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98d370568f393215d605304cdb77b3d5539bd192c75b623c7304c42c8d6d8273"}, - {file = "yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf"}, - {file = "yarl-1.25.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d45673badd08456d0340e9364eddafe1c53a9d2896424294de4d7dd71ad3ee57"}, - {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3"}, - {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:59ba3a6e1aa8cfe5adf4bd270fd965db21955401b7ca6f1696010c55ed4daec2"}, - {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:87796fedc3ba97ec14fab55acb48584276e6c1e4c1e89c422bda62c838e754a9"}, - {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bd0912757081f89b107d6c00b2ff8a194401b0b87eadcf4481de2b865a8fd44f"}, - {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b51c159a9794633f5e0db7ecec7b2b6e3734eca1f5d17dc989ff3552a43ff78b"}, - {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:319e070a01db9920fb63761843f96a104c8e2b9427266731810dc1e22595b17c"}, - {file = "yarl-1.25.1-cp315-cp315-win_amd64.whl", hash = "sha256:a2059a2d891bd156bc5184e7ab7a56e78a84dfcfdeac8c501b552533ad1c36ee"}, - {file = "yarl-1.25.1-cp315-cp315-win_arm64.whl", hash = "sha256:a78b50b4f7918a3de71105d5c0b93bbc57bb8339a4d03a9dfd449f9068e76f3d"}, - {file = "yarl-1.25.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:b5402a340723fa7da00b5cff987ddab61276be6d11251ea71ae02bcac54890d8"}, - {file = "yarl-1.25.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:eda19ea5ee88742f47a2340816e6f2d40b53bed3ab5b69794769f36af9f35bb4"}, - {file = "yarl-1.25.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:75baa6cf9b6d1c52f3e111a130e202fd8cf0a5b3a066c3f73d615e885092e4ec"}, - {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbcef5a9119ef653653132cccaf999b30a0af6f33bb0a4ba80bec30056868487"}, - {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7efc9f082dfed77c316edffa9deb52888e1bc6789171887cc1f68e06d65465c8"}, - {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe01645169a2112aa1d4ebc3e4c5f029c5c8f97adfc32e5d37c993b39a994d75"}, - {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c557dfd5e3db046053a0bdc72261ade790ebe8e2c7a41b36b0ca1f14cb95f3"}, - {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ce4d6ccafb33d39bd78444612d14938ead674c25702ded2ee9c54a47735d225"}, - {file = "yarl-1.25.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80a063f8297fc796296f00f100be520f209b23dc98f93ce8eba6ee7122598209"}, - {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7cb414a73e21a7ab58254926073f2930cb22f5b4314ea4260a687e2b3fd4dce3"}, - {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:85a18376073f8a39aa07be34f9fc77e2869aa72c55c441efdd2cf79a0407504d"}, - {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:77716e245c90f058466a05e6a465bb8600f767a8f4b18b4d40f3aff958e5f73c"}, - {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1e80dcf1446e1b080b1932b0d103c464a04112f5bc31f0f983ad418172063cde"}, - {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bdc8d8b8c22e9e43ac68316b5e6cf083dec537f4ec213cb4aa967b583bc3fa64"}, - {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbf531053a0935f2e871bcd4753f90313688772ff8c017f5ea402e315a78c1f"}, - {file = "yarl-1.25.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b13b88747769537f3d32e89e3a735da10c0a9e35d7322928c701b5f93d3afffd"}, - {file = "yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25"}, - {file = "yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3"}, - {file = "yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7"}, + {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] @@ -1618,9 +1653,11 @@ multidict = ">=4.0" propcache = ">=0.2.1" [extras] -aiohttp = ["aiohttp", "httpx-aiohttp"] +aiohttp = ["httpx-aiohttp"] +datastream = ["wasmtime", "websockets"] +rulesengine = ["wasmtime"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "4eaeff06fb8e558535aa3729ed6f99c2398eaaba665bcdf9c096bb08d9cb5fc6" +content-hash = "b97aece3e401780396eb371b240c4c82f59799ccece668843b9f37dd9b0af950" From dfad7ea5206c4af08cb4d74e07bd307b01eda202 Mon Sep 17 00:00:00 2001 From: Ben Papillon Date: Tue, 15 Sep 2026 16:11:15 -0700 Subject: [PATCH 4/5] update to 1.4.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 21cebff5..6ceaf29b 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 = [] From c80d969c4bb001f6bcdf6cb25f7ca8b7e6350fc3 Mon Sep 17 00:00:00 2001 From: Ben Papillon Date: Tue, 15 Sep 2026 16:12:28 -0700 Subject: [PATCH 5/5] chore(fern): ignore the leases package and conformance suite --- .fernignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.fernignore b/.fernignore index da5ab92e..ce838ad4 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