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