diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..76462f7 --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +# Copy to .env for local development. Never commit the real thing. + +# Postgres in production. SQLite is fine on a laptop, but the append-only +# triggers on `document` and `access_event` are Postgres-only, so the guarantee +# they provide is not in force against SQLite. +PODARENA_DATABASE_URL=postgresql+psycopg://podarena:podarena@localhost:5432/podarena +# PODARENA_DATABASE_URL=sqlite:///var/dev.db + +PODARENA_BLOB_DIR=var/blobs +PODARENA_PUBLIC_BASE_URL=http://localhost:8000 + +# Runs the synthetic corpus and adds a plainly fake broker domain to the DKIM +# allow-list. Must be false anywhere real evidence is stored. +PODARENA_DEMO_FIXTURES=true + +# Monad Testnet +PODARENA_CHAIN_ID=10143 +PODARENA_RPC_URL=https://testnet-rpc.monad.xyz +PODARENA_EXPLORER_URL=https://testnet.monadvision.com + +# Fill these after deploying the contract. Fund the key at faucet.monad.xyz. +# Leave them empty and the app computes roots without anchoring them. +PODARENA_REGISTRY_ADDRESS= +PODARENA_ANCHOR_PRIVATE_KEY= + +# Guards ingest and invite creation, and signs you into the manager console at +# /manage. Required when PODARENA_DEMO_FIXTURES=false. On a fixtures instance it +# is served by /api/config so the login screen can fill it in — which is exactly +# why it must never hold a value you would mind reading out loud. +PODARENA_ADMIN_TOKEN=demo-manager-token + +# Object storage instead of a local disk (optional) +# PODARENA_S3_BUCKET= +# PODARENA_S3_ENDPOINT_URL= +# PODARENA_AWS_ACCESS_KEY_ID= +# PODARENA_AWS_SECRET_ACCESS_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3929f72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +.DS_Store +__pycache__/ +*.py[cod] +.venv/ +.env +.env.* +!.env.example +*.egg-info/ +.pytest_cache/ + +# blobs are content-addressed evidence; never commit them +var/ +blobs/ + +# frontend +web/node_modules/ +web/dist/ +web/.vite/ + +# foundry +contracts/out/ +contracts/cache/ +contracts/broadcast/ +contracts/lib/ + +# editors / local tooling +.idea/ +.claude/settings.local.json + +# typescript incremental build cache +web/tsconfig.tsbuildinfo diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8099d6f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# pod-shop-arena + +A Monad Blitz London hackathon project. Full ruleset, tooling list, and Monad +cheat-sheet: [`docs/blitz-rules.md`](./docs/blitz-rules.md) — that doc is the only copy +of the organisers' brief we keep. + +## The clock + +**Submission freezes at 5:45 PM**, at [blitz.devnads.com](https://blitz.devnads.com). +**Submission order sets pitching order.** Work backwards from 5:45 — deployment, +README, screenshots, and fallback recording all have to be done *before* that, not +after. If we're deciding between one more feature and submitting on time, submit. + +## Eligibility gates — miss one and we don't get to pitch + +1. Repo is a fork of `monad-blitz-london`. ✅ this repo already is. +2. `README.md` explains what it does, how to set it up, how to run it. +3. Contracts live on Monad Testnet (Mainnet also allowed; Testnet satisfies both + sources — see the conflict note in the rules doc). +4. Contracts deployed **inside the event timeline** — deploy early and redeploy as we + go, so there's never a moment where nothing is on-chain. +5. The app is **live on the web** at a public URL. Locally hosted = disqualified, + explicitly. + +Treat 2–5 as work items with real deadlines, not paperwork for the end of the day. +Gate 4 can't be fixed retroactively. + +## Hard constraints — these override normal defaults + +- **All code is written fresh, today.** Don't pull in an existing project, don't port + code from another repo, don't scaffold from someone's finished app. Standard libraries + and plain boilerplate (create-next-app, Foundry init, shadcn) are fine. Anything + beyond that isn't. Forking the starter repo is required and doesn't count. +- **Deployed, not mocked.** Keep the live URL and deployed contract addresses in + `README.md`. +- **Public repo.** No secrets, keys, or `.env` values in commits. Check before every + commit. +- **Max 4 team members.** + +## What "good" means here + +Scoring is **50% judge votes, 50% participant votes**, on novelty, innovative mechanics, +problem-solving, and visible experimentation — explicitly *not* polish. So when there's +a trade-off, spend the time on the mechanic that makes people go "huh, that's new" and +let the rough edges be rough. A clone with a nicer UI scores near zero; a weird idea +that only works because of Monad scores well. + +Four paid places (1200 / 800 / 500 / 500 USD), plus tracks and bounties listed in the +info pack — **we don't have the info pack yet, and it's the one input most likely to +change scope.** Get it before locking the idea. + +Concretely, when I propose something: + +- Prefer designs that only work on Monad. 0.3 s blocks, 0.6 s finality, 500M gas/sec — + the pitch should be "this would be absurd on a slow chain." The brief's own list of + what that unlocks: real-time fully on-chain apps, AI agents with on-chain state, + high-frequency gaming and DeFi, Web2-feeling payment rails, heavy ZK. +- Don't gold-plate. No elaborate test suites, abstraction layers, or feature breadth + unless load-bearing for the demo. +- Cut scope toward one impressive flow rather than five half-flows. +- Don't rebuild infra. Indexers, oracles, wallets, account abstraction, and agentic + payment rails already exist on Monad — see the tooling section of the rules doc. +- For anything Monad-specific, read `docs.monad.xyz/llms-full.txt` or + `docs.monad.xyz/developer-essentials` rather than guessing from memory. + +## The demo is a deliverable + +3 minutes, live, to a room of developers plus judges. Every build decision gets checked +against "can this be shown in 3 minutes without explanation?" Keep a demo-ready path +working at all times: if `main` can't be demoed, that's the top-priority bug. + +Organisers' explicit tips, which shape how we build, not just how we present: + +- **Stage the demo.** Pre-fill forms, pre-create an example. The audience should see the + magic, not the typing. Build seed/demo-data scripts as we go. +- **Don't ship a default Tailwind blob.** This is the one place UI matters: pick a brand + kit — typeface, colour, components — early and apply it consistently. Not polish for + its own sake; just don't look unstyled. Then stop. +- **Fewer slides, more demo.** Problem stated in one breath, then straight to the build. + +Before the pitch slot: stable deployment, screenshots of key flows, short fallback +recording. + +## Working style + +- Commit often, small readable commits — the repo is part of the submission and peers + will read it. +- `README.md` is judged. It needs: one-line what-it-is, why it's novel, the live link, + contract addresses, and local setup steps. +- Preferred stack, per the brief: **viem 2.40+** (native Monad testnet/mainnet support) + and **Monad Foundry** (`curl -L https://foundry.category.xyz | bash`) for contracts. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..afb1778 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,288 @@ +# PodShop Arena — Design Practices + +A living record of the conventions actually in force across this repo — product, +architecture, backend, frontend, and the **"New Genre"** visual system. Everything +below is distilled from the code as it stands, not aspiration; file references point +at the canonical example of each practice. When you add code, match what's here. + +Related docs: [README](README.md) · [REQUIREMENTS](REQUIREMENTS.md) · +[docs/ARCHITECTURE](docs/ARCHITECTURE.md) · [docs/IMPLEMENTATION](docs/IMPLEMENTATION.md) · +[docs/SECURITY](docs/SECURITY.md) · [docs/COMPLIANCE](docs/COMPLIANCE.md). + +--- + +## 1. Product design principles (non-negotiable) + +These are the product's spine (REQUIREMENTS.md §2). They are not slogans — each is +enforced by a specific piece of code. Changing the code without honoring the principle +is a regression. + +| Principle | How it's enforced in code | +|---|---| +| **Source of truth only** — no hand-typed metrics, ever | `computeMetrics()` reads *only* a normalized snapshot series (`packages/core/src/metrics/engine.ts`); manual entity fields (bio, strategy) are surfaced but explicitly labeled `unverified` in the UI (`apps/web/src/routes/RecordPage.tsx`). | +| **No made-up metrics** — same inputs → same numbers | One pinned `METHODOLOGY_VERSION` (`metrics/engine.ts`); the engine is pure and the wall-clock is **injected** (`now` param), never read inside the computation. The determinism contract is unit-tested (`packages/core/src/core.test.ts`). | +| **Exclusivity fence** — nothing public, least-privilege | Disclosure filtering happens **server-side** so hidden data never crosses the wire (`apps/api/src/services/disclosure.ts`); invites gate on revoke/expiry/NDA (`apps/api/src/routes/shared.ts`). | +| **Read-only, never custody** | The `Connector` interface exposes a single verb — `pull()`. No trade/withdraw surface exists to call (`packages/core/src/connectors/types.ts`). | +| **Verifiable, not asserted** | Viewers re-fold the Merkle inclusion proof **in their own browser** via WebCrypto and trust local math, not the server's boolean (`apps/web/src/api/client.ts::verifyProofInBrowser`, `components/VerificationPanel.tsx`). | +| **Privacy-preserving proof** | Absolute $ figures and positions are stripped unless the viewer's profile grants them; `zk_proven` is modeled as a first-class badge tier (`packages/core/src/types.ts`). | + +**Rule of thumb:** every number shown to a user must trace back to a hashed snapshot, +carry its methodology version, and be labeled with a trust tier. If you can't show +where a value came from, it doesn't ship. + +--- + +## 2. Repository & module architecture + +**Monorepo, three layers, one direction of dependency.** + +``` +packages/core ── the domain heart. Framework-free, pure, unit-tested. + ▲ Depends on nothing but Node builtins. + │ (workspace:*) +apps/api ── Fastify BFF. Persistence + orchestration + the fence. + ▲ Depends on core. Never imported by core. + │ (HTTP/JSON) +apps/web ── Vite + React SPA. Depends on api only over the wire. +``` + +- **pnpm workspaces** (`pnpm-workspace.yaml`): `packages/*` + `apps/*`, packages named + `@pod-arena/{core,api,web}`, internal deps pinned `workspace:*`. +- **`packages/core` is the source of truth for domain logic and types.** If a rule is + about *what a metric means* or *what shape the data has*, it lives here — not in the + API and not in the web app. The API and web app both re-declare matching TS shapes + and stay in sync by convention (`apps/web/src/api/client.ts` mirrors `core/src/types.ts`). +- **ESM everywhere.** Every package is `"type": "module"`; TS source imports use explicit + `.js` extensions (`import { x } from './foo.js'`) even for `.ts` files. +- **No build step for `core`/`api`.** They run directly through `tsx` + (`node --import tsx …`); only `web` builds (Vite). `core` is consumed straight from + `./src/index.ts` (see its `package.json` `main`/`exports`). +- **Shared TS baseline** (`tsconfig.base.json`): `strict`, **`noUncheckedIndexedAccess`**, + `noImplicitOverride`, `moduleResolution: Bundler`, target/lib ES2022. The strict + index-access setting is why you'll see `arr[i]!` and `?? fallback` throughout — honor it + rather than loosening the config. + +--- + +## 3. Domain layer conventions (`packages/core`) + +- **Types-first.** Canonical domain types live in `src/types.ts` with heavy doc-comments + explaining *why* each field exists and its invariants (ISO date formats, sign + conventions, "ascending by date", "never mutated"). Read those comments before adding + a field. +- **Barrel export.** `src/index.ts` re-exports every public submodule; consumers import + from `@pod-arena/core`, never deep paths. +- **Pure & deterministic.** Functions take data in, return data out. No wall-clock, no + randomness, no I/O inside computation. Where "now" is genuinely needed it's a parameter + (`MetricsInput.now`). This is what makes results reproducible and testable. +- **Provenance travels with the value.** Every `MetricValue` carries `formula` + `inputs` + strings (`metrics/engine.ts`) so the UI's methodology drawer can show how each number + was produced. New metrics must include both. +- **Methodology versioning.** `METHODOLOGY_VERSION` is pinned to every result. **Bump it on + any change to a formula or convention** — the comment on the constant says so. +- **Small, single-purpose modules.** Math is split by concern: `metrics/returns.ts`, + `metrics/risk.ts`, `metrics/ratios.ts`; verification into `hash.ts` + `merkle.ts`. +- **Interface + registry for pluggability.** `Connector` is an interface; real connectors + (Plaid, IBKR, SFTP) "drop in behind the same contract; the pipeline never knows the + difference" (`connectors/types.ts`). The mock is deterministic (seeded). +- **Compliance blocks, it doesn't flag.** `evaluateMarketingRule()` returns + `compliant: false` + `violations[]` when a presentation is non-conforming; a + non-compliant presentation is **blocked from display/export**, not merely warned + (`compliance/disclaimers.ts`). + +--- + +## 4. Backend conventions (`apps/api`) + +- **Fastify BFF; routes are plugins.** Each route module exports an + `xRoutes(app: FastifyInstance)` function registered in `src/server.ts` + (`authRoutes`, `recordRoutes`, `verifyRoutes`, `sharedRoutes`). +- **Config in one place.** All env reads go through `src/env.ts` using the + `process.env.X ?? default` pattern; no `process.env` access scattered elsewhere. +- **Repository pattern over `node:sqlite`.** `src/db/repo.ts` is the only module that + touches the DB. Conventions there: + - A typed `interface` per table row (snake_case columns, mirroring SQL). + - Prepared statements inline; `nowIso()` helper for timestamps. + - **Prefixed UUID ids** by entity type: `ent_`, `usr_`, `con_`, `rec_`, `mst_`, + `anc_`, `inv_`, `aud_`. Keep the prefix scheme when adding tables. + - Emails stored/queried lower-cased. +- **Immutable, content-addressed vault.** Raw pulls are written to `.json` + write-once (`flag: 'wx'`), re-storing identical data is a no-op, and reads re-verify + the content hash against the filename — a mismatch throws a "vault integrity violation" + (`src/vault/index.ts`). The vault is never mutated post-write. +- **Auth is swappable.** Stateless HMAC-signed bearer tokens + scrypt password hashing + (`src/auth/index.ts`); a `preHandler` hook decorates `req.user` (null when absent). + The comment states the intent: swap for WebAuthn/MFA later, **the middleware surface + stays the same**. Preserve that seam. +- **Centralized error handling.** `app.setErrorHandler` maps `ZodError → 400 + {error:'validation_error', issues}` and otherwise honors `err.statusCode`. Input is + validated with **Zod** at the route boundary. +- **Typed result unions for gating.** The fence uses a discriminated `Gate` union + (`{ok:true, invite} | {ok:false, code, error}`) instead of throwing for expected + denials (`routes/shared.ts`). Prefer this for expected, user-facing rejections. +- **Audit logging is a first-class side effect.** Every meaningful action calls + `appendAudit({ actor, action, target, meta, ip })` — landing, NDA accept, view, sync, + verify. Actions are dotted namespaces (`shared.view`, `record.sync`). New sensitive + actions must be audited. +- **The core loop is one orchestrator.** `services/ingestion.ts::syncRecord()` runs the + whole pipeline in numbered steps: pull → store snapshot → normalize → compute metrics → + anchor → audit. Read it as the reference for how the pieces compose. + +--- + +## 5. Frontend conventions (`apps/web`) + +- **Stack:** Vite + React 18 + **TanStack Router / Query / Table**. Routes are defined + declaratively in `src/router.tsx` and registered on the root route. +- **One typed API client.** `src/api/client.ts` owns *all* network access: a single + generic `req()` wrapper (sets JSON + bearer header, throws typed `ApiError`), an + `api` object of endpoint functions, and response interfaces that mirror the backend. + Components never call `fetch` directly. +- **Server state via TanStack Query.** `useQuery` with array `queryKey`s + (`['record', id]`, `['audit', id]`), `enabled: !!user` to defer until authed, and + `useMutation` + `queryClient.invalidateQueries` for writes (`routes/RecordPage.tsx`). +- **Auth context + redirect guard.** `useAuth()` provides `{user, loading, logout}`; + protected pages redirect via `useEffect(() => { if (!loading && !user) navigate(...) })`. +- **Explicit loading / error / empty states as early returns** before the happy path + (see the guard ladder at the top of `RecordPage`). +- **Presentational components take typed props;** shared formatting lives in + `components/format.ts` (`formatMetric`, `metricTone`, `shortHash`, `timeAgo`) — keep + display logic there, not inline. +- **No chart dependency.** Charts are hand-rolled inline SVG (`components/EquityCurve.tsx`) + so "the picture and the numbers can never disagree" — the curve is the same linked-TWR + series the engine produced. Reach for SVG before adding a charting library. +- **Independent verification client-side.** The viewer recomputes the Merkle root with + WebCrypto (`verifyProofInBrowser`) rather than trusting the server's answer. + +--- + +## 6. Visual design system — "New Genre" + +The full system lives in `apps/web/src/styles.css`. It is deliberately opinionated; +its header comment is the manifesto. Core rules: + +### Tokens +- **Everything is a CSS custom property** declared in `:root`. Semantic aliases + (`--surface-card`, `--hairline`, `--text-dim`, `--radius-card`, …) sit on top of raw + named colors (`--color-onyx`, `--color-slate-veil`, …). A **legacy-compatibility layer** + remaps older variable names (`--bg`, `--accent`, `--good`) onto the New Genre palette so + inline styles stay coherent — extend tokens, don't hardcode hexes in components. + +### Color +- **Achromatic UI.** The interface is near-white surfaces + blue-black Onyx text/controls. + Chroma is spent almost nowhere. +- **The dawn-arc gradient IS the brand** — the single hero gradient + (`--gradient-dawn-arc`, charred-umber → steel-blue → cream). It draws the hero banner, + the brand mark, the verification accent band, and even the equity curve stroke. + Emotion is carried by **gradient + type**, not by colorful UI. +- **Gain/loss is read from sign, not color.** `metricTone()` exists but the `.pos`/`.neg` + classes resolve to the *same* Onyx — the numeral's `+`/`−` does the work. Don't + introduce red/green semantics. + +### Typography +- **Three families, clear jobs:** `--font-display` **Fraunces** (editorial condensed + serif) is the brand voice at volume — hero, page titles, footer; `--font-sans` + **Hanken Grotesk** carries all UI; `--font-mono` for hashes, formulas, and pills. +- **Numbers use `font-variant-numeric: tabular-nums`** everywhere they're compared + (metric tiles, tables, method rows). +- Tight tracking (`letter-spacing: -0.01em`/`-0.02em`), low weights (400–500), optical + sizing on display type. `clamp()` for fluid title sizing. + +### Form & elevation +- **Elevation via lightness, never drop shadows.** Cards are a lighter/inset surface with + a **hairline** border (`--hairline`/`--hairline-soft`), not `box-shadow`. +- **Shape language:** 16px card radius (`--radius-card`); **pills** for controls, inputs, + and badges (`--radius-pill: 50px`). +- **Motion is minimal:** 0.15s ease transitions on interactive states; a single `spin` + keyframe. No large animations. + +### Component patterns (class vocabulary) +- **Cards:** `.card` + `.card-head` (title left, quiet meta/`.pill` right). +- **Badges encode the trust hierarchy visually:** `.badge-live` = solid fill (highest + trust), `.badge-zk` = outline, `.badge-unverified` = quiet/greyed. `Badge.tsx` maps + every `BadgeTier` to text + class + an explanatory `title` tooltip. +- **Metric tiles** (`.metric-tile`), **hashlines** (`.hashline` k/v rows for + cryptographic values), **method rows** (label + big value + formula + inputs), + **watermark layer** (fixed, low-opacity, rotated, per-viewer identity), + **dawn-arc accent band** on the verification card. +- **Layout:** sticky frosted-glass header (`backdrop-filter: blur`), full-bleed hero + gradient, `max-width: 1400px` centered container, `.grid-2` (1.6fr/1fr) that collapses + to one column at `max-width: 900px`, dark footer that bookends the dawn arc. + +### Voice +Copy is part of the trust design: *"Proof over PDFs"*, *"Proof, not promises."*, +*"Claims fade. Proof lasts."* Keep product copy terse, confident, and about verifiability. + +--- + +## 7. Trust & verification design (the moat) + +- **Trust is layered and always labeled**, strongest → weakest, encoded as `BadgeTier` + with an explicit `BADGE_RANK` (`packages/core/src/types.ts`): `live_connected` > + `zk_proven` > `third_party_attested` > `document_ingested` > `unverified`. Every metric + surfaced to a viewer carries the tier of the data it came from. +- **Notarization mechanics:** snapshot content-hash → Merkle tree → root → (simulated) + on-chain anchor. Only the root is "published"; raw data never leaves the vault. + Tree hashing is domain-separated SHA-256 over child hex, Bitcoin-style odd-node + duplication (`verification/merkle.ts`). +- **Provenance is surfaced in the UI**, not hidden: the source-attestation strip + (institution · access method · last sync · period · methodology version pill) and the + on-chain verification panel are always present on a record page. +- **Manual context is quarantined.** Anything a human typed (strategy, bio) is rendered + with an `unverified` badge next to it. + +--- + +## 8. Testing & quality gates + +- **Determinism is the contract that gets tested.** `packages/core/src/core.test.ts` runs + via `node --import tsx --test` and asserts same inputs → same hash → same metrics → + same Merkle root, plus tamper-detection and compliance behavior. +- **Typecheck is the broad gate:** `pnpm typecheck` runs `tsc --noEmit` across every + workspace (`pnpm -r`). Keep the tree green under the strict config. +- **Test the domain, not the framework.** Unit tests target `packages/core` (the pure + layer); the API/web layers are thin orchestration/presentation over it. + +--- + +## 9. Deliberate stubs — where practice bends for the spike + +The current tree is an honest vertical slice (see [docs/IMPLEMENTATION.md](docs/IMPLEMENTATION.md)). +Some things are consciously stubbed **behind stable interfaces** so the real +implementation drops in without touching callers: + +- Only the deterministic mock Plaid connector (real ones implement the same `Connector`). +- On-chain anchoring is simulated (`base-sepolia (simulated)`); the Merkle math is real. +- ZK proofs are modeled as a badge tier; no circuit yet. +- Identity (KYC/KYB, passkeys/MFA/step-up) and encryption-at-rest are not built; the + auth middleware surface and SQLite schema already mirror the production shape. + +**The discipline that matters:** when you replace a stub, keep the interface. The mock and +the real thing must be swappable without the pipeline noticing. + +--- + +## Conventions cheat-sheet + +| Area | Convention | +|---|---| +| Language / modules | TypeScript, ESM, `"type":"module"`, `.js` import extensions | +| TS strictness | `strict` + `noUncheckedIndexedAccess` + `noImplicitOverride` (expect `!` / `?? x`) | +| Runtime | `tsx` for `core`/`api` (no build); Vite for `web` | +| Dependency direction | `web → api (HTTP) → core`; `core` depends on nothing | +| Domain types | Defined once in `core/src/types.ts`, mirrored in the web client | +| Determinism | No clock/randomness/I/O in `core`; inject `now`; bump `METHODOLOGY_VERSION` on formula changes | +| DB ids | Prefixed UUIDs: `ent_ usr_ con_ rec_ mst_ anc_ inv_ aud_` | +| DB access | Only through `apps/api/src/db/repo.ts` (repository pattern) | +| Vault | Content-addressed `.json`, write-once, integrity re-checked on read | +| Validation | Zod at route boundaries; central `setErrorHandler` | +| Expected denials | Discriminated result unions (`Gate`), not thrown errors | +| Auditing | `appendAudit()` on every sensitive action; dotted `namespace.action` names | +| Frontend data | TanStack Query, array `queryKey`s, one typed `api` client, no raw `fetch` | +| Charts | Hand-rolled inline SVG, no chart library | +| Color | Achromatic UI; dawn-arc gradient is the only brand color; gain/loss via sign | +| Type | Fraunces (display) / Hanken Grotesk (UI) / mono (hashes); tabular-nums for numbers | +| Elevation | Lightness + hairline borders, never shadows | +| Tokens | CSS custom properties in `:root`; extend tokens, don't hardcode | +| Trust | Every value carries a `BadgeTier`; manual input labeled `unverified` | +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b54c692 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +# Two stages: build the frontend with node, run everything from python. +# The API serves the built bundle itself, so a deployment is one service plus a +# database — no separate static site, no CORS to get wrong. + +FROM node:22-slim AS web +WORKDIR /web +COPY web/package.json web/package-lock.json* ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + + +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.10 /uv /bin/uv + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PROJECT_ENVIRONMENT=/usr/local + +WORKDIR /app + +# Dependencies first, so a code change does not reinstall the world. +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-install-project --no-dev + +COPY core/ ./core/ +COPY adapters/ ./adapters/ +COPY api/ ./api/ +COPY worker/ ./worker/ +COPY verifier/ ./verifier/ +COPY demo/ ./demo/ +COPY scripts/ ./scripts/ +RUN uv sync --frozen --no-dev + +COPY --from=web /web/dist ./web/dist + +# Blobs are write-once evidence. On Render this is a mounted disk; set the S3 +# variables instead if you would rather not depend on one. +ENV PODARENA_BLOB_DIR=/data/blobs +RUN mkdir -p /data/blobs + +EXPOSE 8000 +CMD ["sh", "scripts/start.sh"] diff --git a/README.md b/README.md index f81fc47..d2c49f8 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,329 @@ -# Monad Blitz London Submission Process +# PodShop Arena -## Steps to prepare your project repo: +**A portfolio manager's track record, proved with the broker's own signature and anchored on +Monad — checkable by an allocator who does not trust us and never asks our server +anything.** -1. Visit the `monad-blitz-london` repo (link [here](https://github.com/monad-developers/monad-blitz-london)) and fork it. +--- -![1.png](./screenshots/1.png) +## The idea -2. Give it your project name, a one-liner description, make sure you are forking `main` branch and click `Create Fork` +A track record is worth nothing if the person it flatters typed it. Every existing fix +asks the institution to cooperate — an API, a data feed, a signed letter — and Goldman is +not going to cooperate. -![2.png](https://github.com/monad-developers/monad-blitz-denver/raw/main/screenshots/2.png?raw=true) +But your broker already signs things. Every statement email carries a DKIM signature: +the institution's own key, vouching for the exact bytes, applied years ago without anyone +thinking about it. Nobody has to agree to anything. The proof is already in your inbox. -3. In your fork you can make all the changes you want, add code of your project, create branches, add information to `README.md` , you can change anything and everything. +Two separate jobs, and conflating them is how this goes wrong: -4. For next steps head to [Blitz Portal](https://blitz.devnads.com) \ No newline at end of file +**DKIM proves the bytes came from the institution.** Cryptography. No chain needed. + +**The chain proves nothing was added later.** Anchoring doesn't stop a lie; it stops a +*later* lie. Every ingest gets a sequence number, so a manager who quietly drops a bad +month leaves a gap anyone can read. + +### Why the chain actually earns its place + +Not "immutability" — that's the answer everyone gives and it's mostly decoration. Here it +is a specific mechanical problem: + +**DKIM selectors get retired.** That's normal key hygiene. And when a key leaves DNS, +every signature made under it becomes unverifiable to anyone who didn't write the key down +at the time. A signature you can check today is unverifiable in a year. + +So we capture the DNS TXT record at first verification, store it with the document, and +commit its hash on-chain. That timestamped capture is what keeps a five-year-old signature +checkable. There's a test for exactly this — `tests/test_dkim.py::TestKeyRotation` — where +an archived message fails against live DNS and verifies against the captured record. + +**And why Monad specifically.** We anchor on every ingest and every view. That's only +sensible at 0.3s blocks and 0.6s finality. On a twelve-second chain you batch nightly, and +the moment you batch, you've proved *the day* rather than *the moment* — which is exactly +the property worth having, and exactly what a backfiller needs you to give up. + +--- + +## Live + +| | | +| --- | --- | +| **App** | https://PODARENA_URL_HERE — the staged demo, live | +| **Contract** | [`0xf56b105C35d9C7c443E829A24a6cAb014f9e8353`](https://testnet.monadvision.com/address/0xf56b105C35d9C7c443E829A24a6cAb014f9e8353) — `TrackRecordRegistry`, [deploy tx](https://testnet.monadvision.com/tx/0x9289228705b4de5871eb888420a1d272d2d2b59a2a1538305df4e48a7238ff37) in block 51986893 | +| **Chain** | Monad Testnet, id `10143`, RPC `https://testnet-rpc.monad.xyz` | +| **Explorer** | https://testnet.monadvision.com | + +Three records are already staged there — open the allocator links in +[Demo credentials](#demo-credentials) and nothing needs typing. To run your own instance, +[Deploy](#deploy) takes about five minutes. + +Without a contract address the app still computes and stores Merkle roots — it marks them +unanchored rather than pretending otherwise. + +--- + +## Everything in the demo is synthetic, and says so + +We cannot put a real Interactive Brokers signature in a public repo, and forging one +against the real `interactivebrokers.com` would be both dishonest and useless — anyone +checking live DNS catches it instantly. + +So the fixture corpus signs for `ibkr-demo.podarena.test`, a domain that plainly does not +exist, using an RSA keypair generated when the instance is first built. **The cryptography +is real.** Every message is really signed, every verdict comes from the real verifier +running real RSA, and all five failure modes are exercised against actual signatures. The +broker is the only fake part. + +--- + +## Three records, three arguments + +The demo is staged, not typed. Each record makes exactly one point. + +**`meridian-global-macro`** — twelve monthly statements, each signed by the broker's key. +Everything grades `source_signed`. Annualized TWR 21.89% net, 35.84% gross. + +**`northwind-partners`** — the same twelve numbers. Eleven months signed; the twelfth typed +into a CSV. **`twr_annualized` is identical to the digit — `0.218937808100` — and the whole +record grades `self_reported`.** One row does it. A metric carries `min(tier)` over every +fact that fed it, so a single unverifiable month caps a year of proof. That will feel +punitive to a customer and it is exactly right. + +**`evidence-lab`** — the five ways DKIM breaks, each a real signature with a real verdict: + +| Fixture | Verdict | +| --- | --- | +| Retired selector | **Verifies** — from the captured record; live DNS can't do it | +| `l=` tag, content appended | **Fails** — only a prefix of the body was signed | +| One digit changed after signing | **Fails** — body hash mismatch | +| Hand-forwarded from a mail client | **Fails** — and the forwarder's own signature is shown being dismissed | +| Valid signature, unknown domain | **Fails** — proves that party handled the mail, nothing more | + +--- + +## Three seats, and the walls between them + +Nobody here sees the same product, and the differences *are* the product. A pod shop has +two people inside the wall and one outside it: + +**The fund** is the platform. It signs in with the admin token, sees the whole roster, +and is the only seat that can take a new pod on. + +**The portfolio manager** runs one book. Same console, one pod. They can't open the pod +next door — and the refusal is a `404`, not a `403`, because a refusal that distinguishes +*not yours* from *no such thing* leaks the roster one guess at a time. Their token is +`pm__`, derived by HMAC from the fund's, so knowing yours tells you nothing +about anyone else's. `/api/me` filters the roster server-side: the names of the other +pods never reach a PM's machine to be hidden. + +**The allocator** is outside the wall and never gets an account at all. + +The manager's console — either seat — shows every metric at every basis, the evidence +with each DKIM verdict, the reconciliation findings, the anchor timeline — and, on the +same page, a **disclosure matrix**: one row per section of the payload, one column per +profile, showing what each allocator actually receives. The numbers in it are measured, +not asserted. Each column is the projection run for real and the response serialized, so +the byte count is the byte count: + +| | Summary | Ratios & risk | Full detail | Full + positions | +| --- | ---: | ---: | ---: | ---: | +| Bytes on the wire | 4.0 kB | 11.0 kB | 36.7 kB | 37.4 kB | +| Metrics | 6 | 18 | 18 | 18 | +| Evidence documents | — | — | 13 | 13 | +| NAV series | — | — | 12 | 12 | +| Positions | — | — | — | 3 | + +Click a column and the page renders exactly that payload, using the same components the +allocator's browser uses. Not a mock-up — one fetch against the same `project()` the +allocator path calls, so the preview and the real thing cannot drift. + +An allocator never gets an account. They get one link, bound to their address and to +a profile, watermarked with their name, revocable mid-conversation. Their page states +which profile they are on and what that rung withholds, because a number withheld silently +is indistinguishable from a number that does not exist. + +The wall is server-side and it fails closed: the response is *built up* from what a profile +permits rather than built in full and filtered down. A dash in that table means the key is +absent from the JSON. Opening dev tools finds nothing, because there is nothing to find. +`tests/test_projection.py` asserts it against the serialized payload for every profile; +`tests/test_owner_console.py` checks the matrix against the payloads it describes. + +### Demo credentials + +Sign-in is one click. `/manage` lists one button per seat — *Fund · all 3 pods*, then a +button per PM — and the buttons make the argument before you've even signed in. + +| Seat | How you get in | Reach | +| --- | --- | --- | +| **Fund** | `/manage` → *Fund* | Every pod | +| **Portfolio manager** | `/manage` → *Meridian Global Macro* | That pod, nothing else | +| **Allocator** | `/view/demo-meridian-full` · `/view/demo-northwind-full` · `/view/demo-evidence-lab` · `/view/demo-meridian-summary` | One profile of one pod | + +Open the fund seat, then a PM seat, and watch the roster disappear. + +The last two links are the same fund, `meridian-global-macro`, seen through `full_detail` +and through `summary`. Open both. + +`demo_admin_token` and the PM tokens are served **only** when `PODARENA_DEMO_FIXTURES=true`, +and an instance holding real evidence cannot run the synthetic corpus. Both halves of that +are tested. + +--- + +## Verify it without us + +That's the actual product. Two routes: + +**In the browser.** The record page re-folds all 47 Merkle leaves locally with Web Crypto, +checks a server-supplied proof path against its own fold, and reads the anchored root off +Monad through **an RPC endpoint you type in**. It says *Verified locally* only when the +local maths agrees. Our API computes none of it. + +**Offline.** Download the evidence bundle — the `.eml` files, the DNS keys exactly as +captured, every Merkle proof, the contract address — and run: + +```bash +podarena-verify bundle ./meridian-global-macro-seq1.zip +``` + +Every check runs with no network and no account. The only thing it can't do offline is +read the chain, so it prints the `cast call` you should run against an RPC of your choice. + +If the bundle ever disagrees with what our UI told you, believe the bundle. + +--- + +## Run it locally + +Needs Python 3.12 (via [uv](https://docs.astral.sh/uv/)) and Node 22. + +```bash +uv sync +(cd web && npm install && npm run build) + +export PODARENA_DATABASE_URL="sqlite:///var/dev.db" # Postgres in production +export PODARENA_ADMIN_TOKEN="demo-manager-token" # opens /manage; pick anything +uv run python -m adapters.bootstrap # schema + append-only triggers +uv run python -m demo.seed --reset --no-anchor # generate the corpus, stage the demo +uv run uvicorn api.app:app --port 8000 +``` + +Then open http://localhost:8000. The two doors are on the front page: `/manage` for the +manager — the login screen fills your token in — and the allocator links, which the seed +script also prints. + +```bash +uv run pytest # 99 tests +(cd contracts && forge install && forge test) # 9 contract tests +``` + +SQLite is a laptop convenience. The append-only triggers on `document` and `access_event` +are Postgres-only, so that guarantee isn't in force against SQLite — see +`adapters/db.py`. + +--- + +## Deploy + +**The contract**, to Monad Testnet: + +```bash +cd contracts +forge install +export PODARENA_RPC_URL=https://testnet-rpc.monad.xyz +export PODARENA_ANCHOR_PRIVATE_KEY=0x... # funded at faucet.monad.xyz +forge script script/Deploy.s.sol:Deploy --rpc-url $PODARENA_RPC_URL --broadcast +``` + +**The app**, to Render: push the repo, then New → Blueprint pointed at `render.yaml`. It +provisions the web service, a Postgres, and a 1GB disk for evidence blobs, and wires +everything except the two chain secrets. Set `PODARENA_REGISTRY_ADDRESS` and +`PODARENA_ANCHOR_PRIVATE_KEY` in the dashboard afterwards and redeploy. + +Set `PODARENA_S3_BUCKET` instead if you'd rather keep blobs in object storage than on a +disk. + +Before this ever holds a real track record, turn off `PODARENA_DEMO_FIXTURES` — demo mode +adds the fake broker domain to the DKIM allow-list. + +--- + +## How it's put together + +``` +contracts/ Foundry. TrackRecordRegistry: anchor, logAccess, head. +core/ Pure Python. No I/O, no clock, no network. Types, tier algebra, + DKIM verification, extractors, metrics, Merkle, reconciliation. +adapters/ DNS, blobs, database, chain. Everything impure. +worker/ The seven-step ingest pipeline. +api/ FastAPI, and the disclosure fence. +verifier/ The standalone cold verifier. +web/ React + Vite + viem. Charts are hand-rolled SVG. +demo/ The synthetic corpus and the staged demo. +``` + +`core` depends on nothing. That's what makes the maths reproducible, and it's why +verification is a two-step dance: `required_dns_names()` says what to look up, an adapter +looks it up, and the pure verifier judges. The same function verifies a live message and a +ten-year-old archived one. + +### Four decisions worth arguing about + +**The tree commits to four things, not one.** Every document's hash, every DKIM verdict +*including the hash of the captured DNS key*, every fact, and the metrics result with the +methodology version and every extractor version. Bump a parser and the root moves. A root +over facts alone would let us silently change how we read a document and keep the same +story. + +**Authenticity and interpretation are separate objects.** DKIM proves the bytes left the +institution. It says nothing about whether our parser read `NAV: 41,207,338.22` correctly. +So extractors are pure versioned functions, the version travels with every fact, and you +can always pull the original document and check our reading. + +**Drawdown is measured on the return index, not on NAV.** A manager who returns capital to +investors has not lost money, and a drawdown built from raw NAV says he has. There's a +test where NAV falls 20% on a withdrawal and max drawdown is correctly zero. + +**Money is `BIGINT` minor units; metrics compute in `Decimal` with explicit quantization.** +Not style. Float non-determinism across machines would produce different roots for the same +evidence, and the anchor would mean nothing. `core/canonical.py` refuses to serialize a +float at all. The one departure: a per-unit *price* is a Decimal rather than minor units, +because FX quotes past two decimals and squeezing `182.4750` into cents would destroy real +precision inside a hashed object — see the note in `core/types.py`. + +### What we deliberately didn't build + +**No agent-logs-in scraping.** It's in the tier model so imported data can be graded +honestly, but shipping it buys no stronger signature than Plaid and costs credential +custody plus most brokers' ToS. A liability dressed as a feature. + +**No Redis or task queue.** A message takes milliseconds to verify and parse, and Monad +confirms in well under a second. The pipeline is a plain function, so putting it behind a +queue later is a change of caller, not a rewrite. + +**No Alembic.** There's one deployment and no schema history to migrate. A half-used +migration tool implies a guarantee about upgrade paths that has never been exercised — +`adapters/bootstrap.py` is honest about what this is. + +--- + +## Tests + +99 Python tests and 9 Solidity tests. The ones that matter: + +- **Determinism** — a golden Merkle root checked into the repo, so a different machine and + a different day have to agree. Plus: leaf order doesn't move the root, one minor unit + does, and bumping an extractor version does. +- **DKIM corpus** — all 18 fixtures against real RSA, with a test asserting the verifier + never opens a socket. +- **Tamper drill** — flip a byte: the verdict fails, the tier drops, the root differs, and + the record says so unprompted. +- **Seats** — a PM token opens its own pod and 404s on every route of the pod next + door, and a token forged by swapping the slug fails the MAC. +- **Projection** — every disclosure profile asserted against the *serialized wire payload*, + not the component. A field a profile forbids is never assembled, so there's no + client-side toggle to defeat. +- **Cold verify** — the bundle proves itself with no access to the API. + +Built from [`docs/build-plan.md`](./docs/build-plan.md). diff --git a/adapters/__init__.py b/adapters/__init__.py new file mode 100644 index 0000000..d4b921f --- /dev/null +++ b/adapters/__init__.py @@ -0,0 +1,6 @@ +"""Everything impure: DNS, blobs, the database, the chain. + +`core` depends on none of it. That is what makes the maths reproducible and the +determinism testable — and it is why the standalone verifier can re-run the same +DKIM check with no network at all. +""" diff --git a/adapters/abi/TrackRecordRegistry.json b/adapters/abi/TrackRecordRegistry.json new file mode 100644 index 0000000..aa2deb3 --- /dev/null +++ b/adapters/abi/TrackRecordRegistry.json @@ -0,0 +1,191 @@ +[ + { + "type": "function", + "name": "anchor", + "inputs": [ + { + "name": "recordId", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "root", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "seq", + "type": "uint64", + "internalType": "uint64" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "custodian", + "inputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "head", + "inputs": [ + { + "name": "recordId", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "root", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "seq", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "ts", + "type": "uint64", + "internalType": "uint64" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "logAccess", + "inputs": [ + { + "name": "recordId", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "viewerCommitment", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "profile", + "type": "uint8", + "internalType": "uint8" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "Accessed", + "inputs": [ + { + "name": "recordId", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "viewerCommitment", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + }, + { + "name": "profile", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + }, + { + "name": "ts", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Anchored", + "inputs": [ + { + "name": "recordId", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "root", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + }, + { + "name": "seq", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + }, + { + "name": "ts", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + }, + { + "name": "by", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "EmptyRoot", + "inputs": [] + }, + { + "type": "error", + "name": "NotCustodian", + "inputs": [ + { + "name": "recordId", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "caller", + "type": "address", + "internalType": "address" + }, + { + "name": "expected", + "type": "address", + "internalType": "address" + } + ] + } +] \ No newline at end of file diff --git a/adapters/blobs.py b/adapters/blobs.py new file mode 100644 index 0000000..5ec8998 --- /dev/null +++ b/adapters/blobs.py @@ -0,0 +1,133 @@ +"""Content-addressed, write-once storage for raw documents. + +Keyed by the SHA-256 of the bytes, so re-ingesting the same message is a no-op +and a stored blob can never disagree with the hash we committed to. Writes never +overwrite: if the key exists, the content is already exactly right by +definition, and if it somehow isn't, silently replacing it would destroy the +evidence rather than surface the problem. +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from typing import Protocol + +from adapters.config import settings + + +def content_key(data: bytes) -> str: + digest = hashlib.sha256(data).hexdigest() + # Sharded so a directory listing stays usable at a few hundred thousand + # documents. + return f"{digest[:2]}/{digest[2:4]}/{digest}" + + +class BlobStore(Protocol): + def put(self, data: bytes) -> str: ... + + def get(self, key: str) -> bytes: ... + + def exists(self, key: str) -> bool: ... + + +class FilesystemBlobStore: + """For local development, and for Render with a mounted disk.""" + + def __init__(self, root: Path) -> None: + self.root = Path(root) + self.root.mkdir(parents=True, exist_ok=True) + + def _path(self, key: str) -> Path: + path = (self.root / key).resolve() + root = self.root.resolve() + if not str(path).startswith(str(root)): + raise ValueError(f"blob key escapes the store root: {key!r}") + return path + + def put(self, data: bytes) -> str: + key = content_key(data) + path = self._path(key) + if path.exists(): + return key + path.parent.mkdir(parents=True, exist_ok=True) + # Write to a temporary name and rename, so a crash mid-write cannot + # leave a truncated blob sitting under a hash that promises otherwise. + temp = path.with_suffix(".partial") + temp.write_bytes(data) + temp.rename(path) + return key + + def get(self, key: str) -> bytes: + return self._path(key).read_bytes() + + def exists(self, key: str) -> bool: + return self._path(key).exists() + + +class S3BlobStore: + """S3 or any S3-compatible endpoint — R2, B2, Spaces.""" + + def __init__( + self, + bucket: str, + *, + endpoint_url: str | None = None, + region: str = "auto", + access_key: str | None = None, + secret_key: str | None = None, + ) -> None: + import boto3 + + self.bucket = bucket + self.client = boto3.client( + "s3", + endpoint_url=endpoint_url, + region_name=region, + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + ) + + def put(self, data: bytes) -> str: + key = content_key(data) + if not self.exists(key): + self.client.put_object(Bucket=self.bucket, Key=key, Body=data) + return key + + def get(self, key: str) -> bytes: + return self.client.get_object(Bucket=self.bucket, Key=key)["Body"].read() + + def exists(self, key: str) -> bool: + from botocore.exceptions import ClientError + + try: + self.client.head_object(Bucket=self.bucket, Key=key) + return True + except ClientError: + return False + + +_store: BlobStore | None = None + + +def blob_store() -> BlobStore: + global _store + if _store is None: + config = settings() + if config.uses_s3: + _store = S3BlobStore( + config.s3_bucket or "", + endpoint_url=config.s3_endpoint_url, + region=config.s3_region, + access_key=config.aws_access_key_id, + secret_key=config.aws_secret_access_key, + ) + else: + _store = FilesystemBlobStore(config.blob_dir) + return _store + + +def reset_blob_store() -> None: + """Tests point the store somewhere temporary.""" + global _store + _store = None diff --git a/adapters/bootstrap.py b/adapters/bootstrap.py new file mode 100644 index 0000000..61e1836 --- /dev/null +++ b/adapters/bootstrap.py @@ -0,0 +1,45 @@ +"""Create the schema and install the append-only triggers. + +Deliberately not Alembic. There is one deployment and no schema history to +migrate, and a half-used migration tool is worse than none — it implies a +guarantee about upgrade paths that has never been exercised. `create_all` is +honest about what this is. The models are ordinary SQLAlchemy, so the day there +is a second deployment to keep in step, `alembic init` and an autogenerated +baseline is a twenty-minute job. + +The triggers are the part that matters and they are installed either way: an +audit trail you can quietly edit is not an audit trail. +""" + +from __future__ import annotations + +from sqlalchemy import inspect + +from adapters import db + + +def main() -> None: + # Printed before the connection is opened, because the failure this most + # often hits is "the URL is not the one you think it is" and a stack trace + # from the driver never says which URL it tried. + print(f" database: {db.database_url().render_as_string(hide_password=True)}") + engine = db.engine() + db.Base.metadata.create_all(engine) + + with engine.begin() as connection: + db.install_append_only_triggers(connection) + + tables = sorted(inspect(engine).get_table_names()) + dialect = engine.dialect.name + print(f" {len(tables)} tables on {dialect}: {', '.join(tables)}") + if dialect == "postgresql": + print(f" append-only triggers on: {', '.join(db.APPEND_ONLY_TABLES)}") + else: + print( + " append-only triggers skipped: this is not Postgres, so that " + "guarantee is not in force here" + ) + + +if __name__ == "__main__": + main() diff --git a/adapters/chain.py b/adapters/chain.py new file mode 100644 index 0000000..643e426 --- /dev/null +++ b/adapters/chain.py @@ -0,0 +1,231 @@ +"""Talking to Monad. + +Anchoring happens inline on every ingest rather than in a nightly batch. That is +a deliberate use of the chain we are on: at 0.3s blocks and 0.6s finality, +waiting for a receipt costs less than a database round trip used to, so the +commitment can be to *the moment* rather than to the day. Batch it and you have +proved that a root existed sometime on Tuesday, which is a much weaker claim and +exactly the one a backfiller needs. + +When no key is configured the client still computes and stores roots, and marks +them unanchored. A missing chain should degrade the proof, not break the app. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import datetime, timezone +from functools import lru_cache +from pathlib import Path +from typing import Protocol + +from adapters.config import settings + +ABI_PATH = Path(__file__).parent / "abi" / "TrackRecordRegistry.json" + + +@lru_cache +def registry_abi() -> list[dict]: + return json.loads(ABI_PATH.read_text()) + + +@dataclass(frozen=True, slots=True) +class AnchorReceipt: + status: str # "confirmed" | "skipped" | "failed" + tx_hash: str | None = None + block_number: int | None = None + seq: int | None = None + confirmed_at: datetime | None = None + error: str | None = None + + @property + def anchored(self) -> bool: + return self.status == "confirmed" + + +@dataclass(frozen=True, slots=True) +class ChainHead: + root: str + seq: int + ts: int + + @property + def exists(self) -> bool: + return self.seq > 0 + + +class ChainClient(Protocol): + def anchor(self, record_key: str, root: str) -> AnchorReceipt: ... + + def head(self, record_key: str) -> ChainHead: ... + + def log_access( + self, record_key: str, viewer_commitment: str, profile: int + ) -> AnchorReceipt: ... + + +def _hexstr(value) -> str: + """Always `0x`-prefixed. + + `HexBytes.hex()` stopped prefixing in hexbytes 1.0, and an unprefixed hash + is silently wrong in two places at once: viem rejects it as a bytes32 + argument, and explorer links 404. + """ + raw = value.hex() if hasattr(value, "hex") else str(value) + return raw if raw.startswith("0x") else "0x" + raw + + +def record_key(slug: str) -> str: + """The bytes32 a record is keyed by on-chain.""" + from web3 import Web3 + + return _hexstr(Web3.keccak(text=slug)) + + +def viewer_commitment(viewer_id: str, salt: str) -> str: + """keccak(viewerId ‖ salt). + + The chain shows that a view happened and when, without publishing who. The + salt is disclosed to the record owner alone, so they can reconstruct the + identities and nobody else can. + """ + from web3 import Web3 + + return _hexstr(Web3.keccak(text=f"{viewer_id}|{salt}")) + + +def _hexbytes(value: str) -> bytes: + return bytes.fromhex(value.removeprefix("0x")) + + +class DisabledChainClient: + """No key, no contract, no anchoring — and the app says so plainly rather + than pretending a root was committed.""" + + reason = "no registry address or signing key configured" + + def anchor(self, record_key: str, root: str) -> AnchorReceipt: + return AnchorReceipt(status="skipped", error=self.reason) + + def head(self, record_key: str) -> ChainHead: + return ChainHead(root="0x" + "00" * 32, seq=0, ts=0) + + def log_access( + self, record_key: str, viewer_commitment: str, profile: int + ) -> AnchorReceipt: + return AnchorReceipt(status="skipped", error=self.reason) + + +class MonadChainClient: + def __init__( + self, + rpc_url: str, + chain_id: int, + registry_address: str, + private_key: str, + *, + timeout: int = 30, + ) -> None: + from web3 import Web3 + from web3.middleware import ExtraDataToPOAMiddleware + + self.w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": timeout})) + self.w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0) + self.chain_id = chain_id + self.account = self.w3.eth.account.from_key(private_key) + self.contract = self.w3.eth.contract( + address=Web3.to_checksum_address(registry_address), abi=registry_abi() + ) + self.timeout = timeout + + @property + def address(self) -> str: + return self.account.address + + def _send(self, function) -> AnchorReceipt: + try: + tx = function.build_transaction( + { + "from": self.account.address, + "nonce": self.w3.eth.get_transaction_count( + self.account.address, "pending" + ), + "chainId": self.chain_id, + } + ) + signed = self.account.sign_transaction(tx) + tx_hash = self.w3.eth.send_raw_transaction(signed.raw_transaction) + receipt = self.w3.eth.wait_for_transaction_receipt( + tx_hash, timeout=self.timeout + ) + except Exception as exc: + return AnchorReceipt(status="failed", error=f"{type(exc).__name__}: {exc}") + + if receipt["status"] != 1: + return AnchorReceipt( + status="failed", + tx_hash=_hexstr(receipt["transactionHash"]), + error="transaction reverted", + ) + return AnchorReceipt( + status="confirmed", + tx_hash=_hexstr(receipt["transactionHash"]), + block_number=receipt["blockNumber"], + confirmed_at=datetime.now(timezone.utc), + ) + + def anchor(self, record_key: str, root: str) -> AnchorReceipt: + receipt = self._send( + self.contract.functions.anchor(_hexbytes(record_key), _hexbytes(root)) + ) + if receipt.anchored: + # Read the sequence back from state rather than trusting what we + # think we sent. A gap in this number is the whole anti-backfill + # signal, so it is worth one extra call to be sure of it. + head = self.head(record_key) + return AnchorReceipt( + status=receipt.status, + tx_hash=receipt.tx_hash, + block_number=receipt.block_number, + seq=head.seq, + confirmed_at=receipt.confirmed_at, + ) + return receipt + + def log_access( + self, record_key: str, viewer_commitment: str, profile: int + ) -> AnchorReceipt: + return self._send( + self.contract.functions.logAccess( + _hexbytes(record_key), _hexbytes(viewer_commitment), profile + ) + ) + + def head(self, record_key: str) -> ChainHead: + root, seq, ts = self.contract.functions.head(_hexbytes(record_key)).call() + return ChainHead(root=_hexstr(root), seq=int(seq), ts=int(ts)) + + +_client: ChainClient | None = None + + +def chain_client() -> ChainClient: + global _client + if _client is None: + config = settings() + if not config.anchoring_enabled: + _client = DisabledChainClient() + else: + _client = MonadChainClient( + config.rpc_url, + config.chain_id, + config.registry_address or "", + config.anchor_private_key or "", + ) + return _client + + +def reset_chain_client() -> None: + global _client + _client = None diff --git a/adapters/config.py b/adapters/config.py new file mode 100644 index 0000000..8139ce9 --- /dev/null +++ b/adapters/config.py @@ -0,0 +1,90 @@ +"""Configuration. Everything that varies between a laptop and Render. + +Note what is *not* here: the institution allow-list. Which domains we will +believe a signature from is a security decision, so it lives in code, in version +control, next to a test — not in an environment variable somebody can widen at +three in the morning. +""" + +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + +from pydantic import field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +# Monad Testnet, per docs.monad.xyz/developer-essentials. +MONAD_TESTNET_CHAIN_ID = 10143 +MONAD_TESTNET_RPC = "https://testnet-rpc.monad.xyz" +MONAD_TESTNET_EXPLORER = "https://testnet.monadvision.com" + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", env_prefix="PODARENA_", extra="ignore" + ) + + database_url: str = "postgresql+psycopg://podarena:podarena@localhost:5432/podarena" + + # Raw documents are content-addressed and write-once. They never go in the + # database — a multi-megabyte blob in a row is a slow way to lose both. + blob_dir: Path = Path("var/blobs") + # Where the synthetic corpus is written on first generation, so that fresh + # keys are not minted on every boot. + fixture_dir: Path = Path("var/fixtures") + s3_bucket: str | None = None + s3_endpoint_url: str | None = None + s3_region: str = "auto" + aws_access_key_id: str | None = None + aws_secret_access_key: str | None = None + + # Chain + rpc_url: str = MONAD_TESTNET_RPC + chain_id: int = MONAD_TESTNET_CHAIN_ID + explorer_url: str = MONAD_TESTNET_EXPLORER + registry_address: str | None = None + anchor_private_key: str | None = None + + # DNS lookups during ingest. Kept short: a slow resolver should degrade the + # verdict to "no key captured", not hang the request. + dns_timeout_seconds: float = 5.0 + + # Runs the synthetic corpus. Adds a plainly fake demo domain to the DKIM + # allow-list, so it must never be on for a deployment holding real records. + demo_fixtures: bool = True + + admin_token: str | None = None + public_base_url: str = "http://localhost:8000" + + @field_validator("database_url") + @classmethod + def _normalize_database_url(cls, value: str) -> str: + """Render hands out `postgres://`, which SQLAlchemy 2 does not accept. + + Rewriting it here beats making every deployment remember to, and beats + the alternative of a confusing boot failure ten seconds into a deploy. + """ + for prefix in ("postgres://", "postgresql://"): + if value.startswith(prefix): + return "postgresql+psycopg://" + value[len(prefix) :] + return value + + @property + def anchoring_enabled(self) -> bool: + return bool(self.registry_address and self.anchor_private_key) + + @property + def uses_s3(self) -> bool: + return bool(self.s3_bucket) + + def explorer_tx(self, tx_hash: str) -> str: + return f"{self.explorer_url.rstrip('/')}/tx/{tx_hash}" + + def explorer_address(self, address: str) -> str: + return f"{self.explorer_url.rstrip('/')}/address/{address}" + + +@lru_cache +def settings() -> Settings: + return Settings() diff --git a/adapters/db.py b/adapters/db.py new file mode 100644 index 0000000..5c62c1e --- /dev/null +++ b/adapters/db.py @@ -0,0 +1,386 @@ +"""Persistence. + +Two rules from the plan are enforced here rather than hoped for: + +Money is `BIGINT` minor units plus a currency code. There is no float column in +this schema and there never will be, because a metric computed from a float NAV +would hash differently on different machines and break the anchor. + +`document` and `access_event` are append-only, by convention *and* by a database +trigger that rejects UPDATE and DELETE. An audit trail you can quietly edit is +not an audit trail. +""" + +from __future__ import annotations + +import uuid +from datetime import date, datetime, timezone +from decimal import Decimal +from pathlib import Path + +from sqlalchemy import ( + BigInteger, + Boolean, + Date, + DateTime, + ForeignKey, + Index, + Integer, + JSON, + Numeric, + String, + Text, + UniqueConstraint, + create_engine, + text, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.engine import make_url +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker + +from adapters.config import settings + +# JSONB where we have it, plain JSON elsewhere, so a laptop running SQLite and +# Render running Postgres share one set of models. +JSONType = JSON().with_variant(JSONB(), "postgresql") + +# Quantity and price are exact decimals, not floats. 12 places matches the +# canonical serializer's scale, so a round-trip through the database cannot +# change a hash. +EXACT = Numeric(38, 12, asdecimal=True) + + +def _uuid() -> str: + return str(uuid.uuid4()) + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def as_utc(value: datetime | None) -> datetime | None: + """Re-attach the timezone a driver dropped. + + Postgres hands back aware datetimes; SQLite does not, and a naive datetime + reaching the canonical serializer is a hard error by design — there is no + correct guess for which timezone it meant. Every datetime is stored as UTC, + so the fix is to say so on the way out rather than to weaken the serializer. + """ + if value is None: + return None + return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc) + + +class Base(DeclarativeBase): + pass + + +class Entity(Base): + __tablename__ = "entity" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + name: Mapped[str] = mapped_column(String(200)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + accounts: Mapped[list["Account"]] = relationship(back_populates="entity") + records: Mapped[list["Record"]] = relationship(back_populates="entity") + + +class Account(Base): + __tablename__ = "account" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + entity_id: Mapped[str] = mapped_column(ForeignKey("entity.id")) + institution_id: Mapped[str] = mapped_column(String(64)) + external_ref: Mapped[str | None] = mapped_column(String(120), nullable=True) + currency: Mapped[str] = mapped_column(String(5), default="USD") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + entity: Mapped[Entity] = relationship(back_populates="accounts") + + +class Record(Base): + """A track record: the thing that gets anchored, shown, and argued about.""" + + __tablename__ = "record" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + entity_id: Mapped[str] = mapped_column(ForeignKey("entity.id")) + slug: Mapped[str] = mapped_column(String(80), unique=True) + name: Mapped[str] = mapped_column(String(200)) + strategy: Mapped[str | None] = mapped_column(String(200), nullable=True) + # keccak(record slug) — the bytes32 the contract is keyed by + chain_key: Mapped[str] = mapped_column(String(66)) + viewer_salt: Mapped[str] = mapped_column(String(66), default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + entity: Mapped[Entity] = relationship(back_populates="records") + snapshots: Mapped[list["Snapshot"]] = relationship(back_populates="record") + + +class Document(Base): + """Write-once. Enforced by trigger, not by good intentions.""" + + __tablename__ = "document" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + entity_id: Mapped[str] = mapped_column(ForeignKey("entity.id")) + record_id: Mapped[str | None] = mapped_column( + ForeignKey("record.id"), nullable=True, index=True + ) + sha256: Mapped[str] = mapped_column(String(64), index=True) + blob_key: Mapped[str] = mapped_column(String(200)) + byte_length: Mapped[int] = mapped_column(Integer) + received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + channel: Mapped[str] = mapped_column(String(32)) + institution_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + doc_type: Mapped[str] = mapped_column(String(40), default="unknown") + filename: Mapped[str | None] = mapped_column(String(255), nullable=True) + + # Content-addressing is global in the blob store, where identical bytes + # genuinely are one object. A *document* is an entity's copy of those bytes, + # so two funds holding the same file each get their own row and their own + # verdict rather than silently sharing one. + __table_args__ = (UniqueConstraint("entity_id", "sha256", name="uq_document_entity_sha"),) + + +class DkimVerdictRow(Base): + __tablename__ = "dkim_verdict" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + document_id: Mapped[str] = mapped_column(ForeignKey("document.id"), index=True) + verified: Mapped[bool] = mapped_column(Boolean, default=False) + d_domain: Mapped[str | None] = mapped_column(String(255), nullable=True) + selector: Mapped[str | None] = mapped_column(String(120), nullable=True) + algo: Mapped[str | None] = mapped_column(String(40), nullable=True) + l_tag_present: Mapped[bool] = mapped_column(Boolean, default=False) + body_hash_matched: Mapped[bool] = mapped_column(Boolean, default=False) + # The captured key. This column is the reason anchoring earns its place: + # once the selector leaves DNS, this is the only way the signature stays + # checkable, and its hash is committed on-chain. + dns_txt_record: Mapped[str | None] = mapped_column(Text, nullable=True) + dns_captured_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + signed_headers: Mapped[dict] = mapped_column(JSONType, default=list) + signature_summaries: Mapped[dict] = mapped_column(JSONType, default=list) + failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + + +class ExtractionRow(Base): + __tablename__ = "extraction" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + document_id: Mapped[str] = mapped_column(ForeignKey("document.id"), index=True) + extractor_id: Mapped[str] = mapped_column(String(80)) + extractor_version: Mapped[str] = mapped_column(String(20)) + status: Mapped[str] = mapped_column(String(20)) + payload: Mapped[dict] = mapped_column(JSONType, default=dict) + error: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + +class FactRow(Base): + __tablename__ = "fact" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + record_id: Mapped[str] = mapped_column(ForeignKey("record.id"), index=True) + account_id: Mapped[str] = mapped_column(ForeignKey("account.id")) + document_id: Mapped[str] = mapped_column(ForeignKey("document.id")) + as_of: Mapped[date] = mapped_column(Date, index=True) + kind: Mapped[str] = mapped_column(String(20)) + instrument: Mapped[str | None] = mapped_column(String(80), nullable=True) + quantity: Mapped[Decimal | None] = mapped_column(EXACT, nullable=True) + price: Mapped[Decimal | None] = mapped_column(EXACT, nullable=True) + amount_minor: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + currency: Mapped[str] = mapped_column(String(5)) + tier: Mapped[str] = mapped_column(String(24)) + extractor_version: Mapped[str | None] = mapped_column(String(20), nullable=True) + note: Mapped[str | None] = mapped_column(String(255), nullable=True) + + __table_args__ = (Index("ix_fact_record_asof", "record_id", "as_of"),) + + +class Snapshot(Base): + __tablename__ = "snapshot" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + record_id: Mapped[str] = mapped_column(ForeignKey("record.id"), index=True) + seq: Mapped[int] = mapped_column(Integer) + root: Mapped[str] = mapped_column(String(66)) + metrics_hash: Mapped[str] = mapped_column(String(64)) + methodology_version: Mapped[str] = mapped_column(String(20)) + extractor_versions: Mapped[dict] = mapped_column(JSONType, default=dict) + tier: Mapped[str] = mapped_column(String(24)) + leaves: Mapped[dict] = mapped_column(JSONType, default=list) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + record: Mapped[Record] = relationship(back_populates="snapshots") + metrics: Mapped[list["MetricRow"]] = relationship(back_populates="snapshot") + findings: Mapped[list["FindingRow"]] = relationship(back_populates="snapshot") + anchor: Mapped["Anchor | None"] = relationship(back_populates="snapshot", uselist=False) + + # seq is strictly monotonic per record; a gap is visible on-chain and is + # how backfilling gets caught. + __table_args__ = (UniqueConstraint("record_id", "seq", name="uq_snapshot_seq"),) + + +class MetricRow(Base): + __tablename__ = "metric_result" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + snapshot_id: Mapped[str] = mapped_column(ForeignKey("snapshot.id"), index=True) + key: Mapped[str] = mapped_column(String(40)) + basis: Mapped[str] = mapped_column(String(10)) + value: Mapped[Decimal] = mapped_column(EXACT) + unit: Mapped[str] = mapped_column(String(20)) + formula: Mapped[str] = mapped_column(Text) + inputs: Mapped[dict] = mapped_column(JSONType, default=dict) + tier: Mapped[str] = mapped_column(String(24)) + + snapshot: Mapped[Snapshot] = relationship(back_populates="metrics") + + +class FindingRow(Base): + __tablename__ = "finding" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + snapshot_id: Mapped[str] = mapped_column(ForeignKey("snapshot.id"), index=True) + kind: Mapped[str] = mapped_column(String(40)) + severity: Mapped[str] = mapped_column(String(12)) + detail: Mapped[str] = mapped_column(Text) + as_of: Mapped[date | None] = mapped_column(Date, nullable=True) + + snapshot: Mapped[Snapshot] = relationship(back_populates="findings") + + +class Anchor(Base): + __tablename__ = "anchor" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + snapshot_id: Mapped[str] = mapped_column(ForeignKey("snapshot.id"), unique=True) + chain_id: Mapped[int] = mapped_column(Integer) + root: Mapped[str] = mapped_column(String(66)) + seq: Mapped[int] = mapped_column(Integer) + status: Mapped[str] = mapped_column(String(20), default="pending") + tx_hash: Mapped[str | None] = mapped_column(String(66), nullable=True) + block_number: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + confirmed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + error: Mapped[str | None] = mapped_column(Text, nullable=True) + + snapshot: Mapped[Snapshot] = relationship(back_populates="anchor") + + +class Invite(Base): + """Bound to an identity and a disclosure profile. Single-use, expiring, + revocable — not a public URL with a long random string in it.""" + + __tablename__ = "invite" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + record_id: Mapped[str] = mapped_column(ForeignKey("record.id"), index=True) + viewer_email: Mapped[str] = mapped_column(String(255)) + profile: Mapped[str] = mapped_column(String(32), default="summary") + nda_version: Mapped[str] = mapped_column(String(20), default="1.0") + token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + revoked_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + nda_accepted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + +class AccessEvent(Base): + """Append-only, by trigger.""" + + __tablename__ = "access_event" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + invite_id: Mapped[str] = mapped_column(ForeignKey("invite.id"), index=True) + action: Mapped[str] = mapped_column(String(40)) + at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + ip: Mapped[str | None] = mapped_column(String(64), nullable=True) + ua: Mapped[str | None] = mapped_column(String(255), nullable=True) + detail: Mapped[dict] = mapped_column(JSONType, default=dict) + chain_tx: Mapped[str | None] = mapped_column(String(66), nullable=True) + + +APPEND_ONLY_TABLES = ("document", "access_event") + + +def append_only_sql(table: str) -> tuple[str, str]: + """Postgres trigger that refuses UPDATE and DELETE on a table.""" + function = f""" + CREATE OR REPLACE FUNCTION {table}_is_append_only() RETURNS trigger AS $$ + BEGIN + RAISE EXCEPTION '{table} is append-only: % is not permitted', TG_OP; + END; + $$ LANGUAGE plpgsql; + """ + trigger = f""" + CREATE TRIGGER {table}_append_only + BEFORE UPDATE OR DELETE ON {table} + FOR EACH ROW EXECUTE FUNCTION {table}_is_append_only(); + """ + return function, trigger + + +def install_append_only_triggers(connection) -> None: + if connection.dialect.name != "postgresql": + # SQLite is a local-development convenience only. The guarantee is a + # Postgres one, and the deployment that holds real evidence runs + # Postgres — see the note in README. + return + for table in APPEND_ONLY_TABLES: + function, trigger = append_only_sql(table) + connection.execute(text(function)) + connection.execute(text(f"DROP TRIGGER IF EXISTS {table}_append_only ON {table}")) + connection.execute(text(trigger)) + + +_engine = None +_Session = None + + +def database_url(): + """The configured URL, password removed, safe to print in a boot log.""" + return make_url(settings().database_url) + + +def engine(): + global _engine + if _engine is None: + url = database_url() + kwargs = {"pool_pre_ping": True, "future": True} + if url.drivername.startswith("sqlite"): + kwargs["connect_args"] = {"check_same_thread": False} + # SQLite will not create a missing directory. It says "unable to + # open database file" and leaves you to guess which file it meant — + # which is exactly what a relative URL copied from a laptop does in + # a container, where the working directory is /app and there is no + # /app/var. Create the directory and let the path be the answer. + if url.database and url.database != ":memory:": + Path(url.database).expanduser().resolve().parent.mkdir( + parents=True, exist_ok=True + ) + _engine = create_engine(url, **kwargs) + return _engine + + +def SessionLocal(): + global _Session + if _Session is None: + _Session = sessionmaker(bind=engine(), expire_on_commit=False, future=True) + return _Session() + + +def create_all() -> None: + """Used by tests and the local bootstrap. Render runs Alembic.""" + Base.metadata.create_all(engine()) + with engine().begin() as connection: + install_append_only_triggers(connection) diff --git a/adapters/dns.py b/adapters/dns.py new file mode 100644 index 0000000..8d4616a --- /dev/null +++ b/adapters/dns.py @@ -0,0 +1,88 @@ +"""DNS lookups, kept outside the verifier. + +This is the only place that touches a resolver. `core.dkim` takes records as +arguments, which is what lets the same code verify a live message today and an +archived one in ten years from a record captured at the time. + +Capturing is the point. A DKIM selector is retired eventually — that is normal +key hygiene, not misbehaviour — and when it goes, every signature under it +becomes unverifiable to anyone who did not write the key down. So we write it +down at first sight and commit its hash to the chain. +""" + +from __future__ import annotations + +from typing import Iterable, Mapping, Protocol + +import dns.exception +import dns.rdatatype +import dns.resolver + +from adapters.config import settings + + +class DnsResolver(Protocol): + def txt(self, names: Iterable[str]) -> dict[str, str]: ... + + +class LiveResolver: + """Real DNS. Failure is a normal outcome, not an exception to propagate: + a missing key means "we cannot verify this", which is a verdict.""" + + def __init__(self, timeout: float | None = None) -> None: + self.timeout = timeout if timeout is not None else settings().dns_timeout_seconds + + def txt(self, names: Iterable[str]) -> dict[str, str]: + resolver = dns.resolver.Resolver() + resolver.timeout = self.timeout + resolver.lifetime = self.timeout + + found: dict[str, str] = {} + for name in names: + try: + answer = resolver.resolve(name, dns.rdatatype.TXT) + except (dns.exception.DNSException, ValueError): + continue + for record in answer: + # A TXT record longer than 255 bytes arrives as several strings + # that must be joined with no separator. RSA public keys are + # always longer than 255 bytes, so getting this wrong breaks + # every lookup rather than a rare one. + joined = b"".join(record.strings).decode("utf-8", errors="replace") + if "p=" in joined: + found[name] = joined + break + return found + + +class StaticResolver: + """A fixed map of records. Used by the fixture corpus, by tests, and by the + standalone verifier, which reads captured records off disk.""" + + def __init__(self, records: Mapping[str, str]) -> None: + self.records = dict(records) + + def txt(self, names: Iterable[str]) -> dict[str, str]: + return {name: self.records[name] for name in names if name in self.records} + + +class ChainResolver: + """Try each resolver in turn. + + Live DNS first, then whatever we captured before. That order matters: a live + lookup that succeeds is fresh evidence, and the fallback exists precisely + for the selectors that have already gone. + """ + + def __init__(self, *resolvers: DnsResolver) -> None: + self.resolvers = resolvers + + def txt(self, names: Iterable[str]) -> dict[str, str]: + remaining = list(names) + found: dict[str, str] = {} + for resolver in self.resolvers: + if not remaining: + break + found.update(resolver.txt(remaining)) + remaining = [name for name in remaining if name not in found] + return found diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..89041b9 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1 @@ +"""HTTP surface and the disclosure fence.""" diff --git a/api/app.py b/api/app.py new file mode 100644 index 0000000..6d890e2 --- /dev/null +++ b/api/app.py @@ -0,0 +1,1018 @@ +"""HTTP surface. + +The fence lives here: an invite bound to an identity and a profile, an NDA gate +that writes consent before a single byte of data is served, and a projection +that decides what exists in the response at all. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import secrets +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Annotated, Any + +from fastapi import Depends, FastAPI, File, Header, HTTPException, Request, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, Response +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.orm import Session + +from adapters import db +from adapters.blobs import blob_store +from adapters.chain import chain_client, record_key, viewer_commitment +from adapters.config import settings +from api.projection import Profile, project +from core.merkle import MerkleTree +from core.tiers import Tier +from core.types import Channel +from worker import pipeline + +NDA_VERSION = "1.0" +NDA_TEXT = ( + "The information behind this gate is confidential. By continuing you agree " + "not to redistribute it, and you accept that your identity and the time of " + "access are recorded in an append-only log and committed on-chain." +) + +app = FastAPI( + title="PodShop Arena", + version="0.1.0", + description="Track records an allocator can verify without trusting us.", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +def session() -> Session: + db_session = db.SessionLocal() + try: + yield db_session + db_session.commit() + except Exception: + db_session.rollback() + raise + finally: + db_session.close() + + +SessionDep = Annotated[Session, Depends(session)] + + +# -- who is asking ---------------------------------------------------------- +# +# A pod shop has two people on the inside of the wall, and they are not the same +# person. The **fund** allocates across pods: it sees the whole roster, and it is +# the only party that can add a pod or take one on. A **portfolio manager** runs +# one pod: they see their own track record, hand out their own links, and cannot +# so much as read the name of the pod next door. The **allocator** is outside the +# wall entirely and never gets a token at all — see `/api/view/{token}`. +# +# Collapsing the first two into one "admin" would have been less code and a lie: +# the whole product is about who can see what, and a console that shows a PM the +# fund's roster is the same failure it exists to prevent. + + +class Principal(BaseModel): + role: str # "fund" | "pm" + record_slug: str | None = None # set for pm, None for fund + label: str + + def covers(self, slug: str) -> bool: + return self.role == "fund" or self.record_slug == slug + + +def pm_token(slug: str) -> str: + """A pod's token, derived from the fund's. + + Deterministic on purpose — a reseed or a redeploy has to leave the demo + links working, and there is no PM-credential table to migrate. The slug + travels in the clear so the token resolves without a database hit; the MAC + is what makes it unforgeable. A PM who knows their own token learns nothing + about the pod next door, because they can't compute its MAC without the + fund's secret.""" + secret = settings().admin_token or "open-instance" + mac = hmac.new(secret.encode(), f"pm:{slug}".encode(), hashlib.sha256) + return f"pm_{slug}_{mac.hexdigest()[:20]}" + + +def require_principal(authorization: Annotated[str | None, Header()] = None) -> Principal: + supplied = (authorization or "").removeprefix("Bearer ").strip() + expected = settings().admin_token + + if expected and secrets.compare_digest(supplied, expected): + return Principal(role="fund", label="Fund") + + if supplied.startswith("pm_"): + slug = supplied[3:].rpartition("_")[0] + if slug and secrets.compare_digest(pm_token(slug), supplied): + return Principal(role="pm", record_slug=slug, label="Portfolio manager") + + if not expected: + # No token configured means an open instance. Fine for the fixture + # demo, refused the moment real evidence is involved. + if settings().demo_fixtures: + return Principal(role="fund", label="Fund") + raise HTTPException(503, "admin token is not configured") + + raise HTTPException(401, "bad admin token") + + +AdminDep = Annotated[Principal, Depends(require_principal)] + + +def require_fund(who: AdminDep) -> Principal: + """Roster-level authority. A PM is not it.""" + if who.role != "fund": + raise HTTPException(403, "only the fund can do this") + return who + + +FundDep = Annotated[Principal, Depends(require_fund)] + + +def require_scope(slug: str, who: AdminDep) -> Principal: + """Guards every per-record console route. `slug` comes off the path. + + 404, not 403: a PM asking about a pod that isn't theirs shouldn't learn + whether it exists.""" + if not who.covers(slug): + raise HTTPException(404, "no such record") + return who + + +ScopeDep = Annotated[Principal, Depends(require_scope)] + + +def token_hash(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +# -- public ----------------------------------------------------------------- + + +@app.get("/api/health") +def health() -> dict[str, Any]: + return {"status": "ok", "demo_fixtures": settings().demo_fixtures} + + +@app.get("/api/config") +def config() -> dict[str, Any]: + """What the browser needs to check our work against a chain we do not run.""" + cfg = settings() + return { + "chain_id": cfg.chain_id, + "rpc_url": cfg.rpc_url, + "explorer_url": cfg.explorer_url, + "registry_address": cfg.registry_address, + "anchoring_enabled": cfg.anchoring_enabled, + "demo_fixtures": cfg.demo_fixtures, + "methodology_version": __import__("core").METHODOLOGY_VERSION, + # The manager console's password, handed to the browser on purpose so a + # three-minute demo is not three minutes of typing. It is `None` unless + # this instance is running the synthetic corpus, and an instance holding + # real evidence cannot run the synthetic corpus. Both halves of that are + # tested — see tests/test_owner_console.py. + "demo_admin_token": cfg.admin_token if cfg.demo_fixtures else None, + } + + +@app.get("/api/demo-logins") +def demo_logins(db_session: SessionDep) -> list[dict[str, Any]]: + """One click per role, on a fixtures instance only. + + Same rule as the admin token above: `None` unless this instance is running + the synthetic corpus, and an instance holding real evidence cannot.""" + if not settings().demo_fixtures: + return [] + records = db_session.scalars(select(db.Record).order_by(db.Record.created_at)).all() + logins = [ + { + "role": "fund", + "label": "Fund", + "scope": f"all {len(records)} pods", + "token": settings().admin_token or "", + } + ] + logins += [ + { + "role": "pm", + "label": record.name, + "scope": "this pod only", + "token": pm_token(record.slug), + } + for record in records + ] + return logins + + +@app.get("/api/me") +def me(db_session: SessionDep, who: AdminDep) -> dict[str, Any]: + """Who you are and, therefore, what the console is allowed to list. + + The roster is filtered here rather than in the browser. A PM's console does + not render a list it then hides — the names of the other pods never reach + their machine.""" + records = db_session.scalars(select(db.Record).order_by(db.Record.created_at)).all() + mine = [record for record in records if who.covers(record.slug)] + return { + "role": who.role, + "label": who.label, + "record_slug": who.record_slug, + "records": [ + { + "slug": record.slug, + "name": record.name, + "strategy": record.strategy, + "chain_key": record.chain_key, + } + for record in mine + ], + } + + +@app.get("/api/records") +def list_records(db_session: SessionDep) -> list[dict[str, Any]]: + records = db_session.scalars(select(db.Record).order_by(db.Record.created_at)).all() + return [ + { + "slug": record.slug, + "name": record.name, + "strategy": record.strategy, + "chain_key": record.chain_key, + } + for record in records + ] + + +@app.get("/api/records/{slug}/anchors") +def anchors(slug: str, db_session: SessionDep) -> dict[str, Any]: + """The anchor timeline. Public on purpose — a gap in the sequence is + evidence, and evidence behind a login is not much use to anyone.""" + record = _record(db_session, slug) + rows = db_session.scalars( + select(db.Snapshot) + .where(db.Snapshot.record_id == record.id) + .order_by(db.Snapshot.seq) + ).all() + + timeline = [] + for snapshot in rows: + anchor = db_session.scalar( + select(db.Anchor).where(db.Anchor.snapshot_id == snapshot.id) + ) + timeline.append( + { + "seq": snapshot.seq, + "root": snapshot.root, + "tier": snapshot.tier, + "created_at": snapshot.created_at.isoformat(), + "leaf_count": len(snapshot.leaves or []), + "anchor": { + "status": anchor.status, + "tx_hash": anchor.tx_hash, + "block_number": anchor.block_number, + "explorer_url": settings().explorer_tx(anchor.tx_hash) + if anchor.tx_hash + else None, + } + if anchor + else None, + } + ) + + return { + "record": {"slug": record.slug, "name": record.name}, + "chain_key": record.chain_key, + "chain_id": settings().chain_id, + "registry_address": settings().registry_address, + "snapshots": timeline, + } + + +# -- admin ------------------------------------------------------------------ + + +class RecordIn(BaseModel): + slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{1,79}$") + name: str + strategy: str | None = None + entity_name: str | None = None + + +@app.post("/api/records", status_code=201) +def create_record(body: RecordIn, db_session: SessionDep, _: FundDep) -> dict[str, Any]: + if db_session.scalar(select(db.Record).where(db.Record.slug == body.slug)): + raise HTTPException(409, f"record {body.slug} already exists") + + entity = db.Entity(name=body.entity_name or body.name) + db_session.add(entity) + db_session.flush() + + record = db.Record( + entity_id=entity.id, + slug=body.slug, + name=body.name, + strategy=body.strategy, + chain_key=record_key(body.slug), + viewer_salt=secrets.token_hex(16), + ) + db_session.add(record) + db_session.flush() + return {"slug": record.slug, "id": record.id, "chain_key": record.chain_key} + + +@app.post("/api/records/{slug}/documents") +async def upload_document( + slug: str, + db_session: SessionDep, + _: ScopeDep, + file: UploadFile = File(...), +) -> dict[str, Any]: + """Ingest a raw message. + + The bytes are stored and verified exactly as uploaded. Nothing in this path + re-serializes the message, because any normalization of line endings would + silently destroy the signature. + """ + record = _record(db_session, slug) + raw = await file.read() + if not raw: + raise HTTPException(400, "empty upload") + + outcome = pipeline.ingest( + db_session, + entity_id=record.entity_id, + record_id=record.id, + raw=raw, + channel=Channel.EML_UPLOAD, + filename=file.filename, + ) + return outcome.as_dict() + + +@app.post("/api/records/{slug}/rebuild") +def rebuild(slug: str, db_session: SessionDep, _: ScopeDep) -> dict[str, Any]: + record = _record(db_session, slug) + outcome = pipeline.rebuild(db_session, record_id=record.id) + return { + "seq": outcome.snapshot.seq, + "root": outcome.snapshot.root_hex, + "tier": outcome.snapshot.tier.slug, + "metrics": len(outcome.metrics), + "findings": len(outcome.findings), + "anchor_status": outcome.anchor_status, + "tx_hash": outcome.tx_hash, + "explorer_url": settings().explorer_tx(outcome.tx_hash) + if outcome.tx_hash + else None, + } + + +class InviteIn(BaseModel): + viewer_email: str + profile: str = "summary" + expires_in_hours: int = 168 + + +@app.post("/api/records/{slug}/invites", status_code=201) +def create_invite( + slug: str, body: InviteIn, db_session: SessionDep, _: ScopeDep +) -> dict[str, Any]: + """Issue a single-use, expiring, revocable invite bound to one identity. + + The token is returned exactly once and only its hash is stored, so a leaked + database does not hand anyone a working key to a record. + """ + record = _record(db_session, slug) + try: + profile = Profile.from_slug(body.profile) + except ValueError as exc: + raise HTTPException(422, str(exc)) from None + + token = secrets.token_urlsafe(32) + invite = db.Invite( + record_id=record.id, + viewer_email=body.viewer_email, + profile=profile.slug, + nda_version=NDA_VERSION, + token_hash=token_hash(token), + expires_at=datetime.now(timezone.utc) + timedelta(hours=body.expires_in_hours), + ) + db_session.add(invite) + db_session.flush() + return { + "token": token, + "profile": profile.slug, + "viewer_email": invite.viewer_email, + "expires_at": invite.expires_at.isoformat(), + "url": f"{settings().public_base_url.rstrip('/')}/view/{token}", + } + + +@app.post("/api/invites/{invite_id}/revoke") +def revoke_invite( + invite_id: str, db_session: SessionDep, who: AdminDep +) -> dict[str, Any]: + invite = db_session.get(db.Invite, invite_id) + if invite is None: + raise HTTPException(404, "no such invite") + # The one console route with no slug in the path, so the scope check has to + # walk from the invite back to the pod that issued it. + record = db_session.get(db.Record, invite.record_id) + if record is None or not who.covers(record.slug): + raise HTTPException(404, "no such invite") + invite.revoked_at = datetime.now(timezone.utc) + db_session.flush() + return {"id": invite.id, "revoked_at": invite.revoked_at.isoformat()} + + +def _latest_snapshot(db_session: Session, record: db.Record) -> db.Snapshot: + snapshot = db_session.scalar( + select(db.Snapshot) + .where(db.Snapshot.record_id == record.id) + .order_by(db.Snapshot.seq.desc()) + .limit(1) + ) + if snapshot is None: + raise HTTPException(404, "this record has no snapshot yet") + return snapshot + + +def _projection_inputs(db_session: Session, record: db.Record) -> dict[str, Any]: + snapshot = _latest_snapshot(db_session, record) + return { + "record": record, + "snapshot": snapshot, + "metrics": db_session.scalars( + select(db.MetricRow).where(db.MetricRow.snapshot_id == snapshot.id) + ).all(), + "findings": db_session.scalars( + select(db.FindingRow).where(db.FindingRow.snapshot_id == snapshot.id) + ).all(), + "facts": db_session.scalars( + select(db.FactRow) + .where(db.FactRow.record_id == record.id) + .order_by(db.FactRow.as_of) + ).all(), + "documents": _documents(db_session, record.id), + "anchor": db_session.scalar( + select(db.Anchor).where(db.Anchor.snapshot_id == snapshot.id) + ), + } + + +@app.get("/api/records/{slug}/owner") +def owner_view(slug: str, db_session: SessionDep, _: ScopeDep) -> dict[str, Any]: + """The record as the manager who owns it sees it: everything. + + Same `project()` the allocator path uses, at the widest profile and with no + watermark. Running the owner view through the projection rather than around + it is the point — if a field is invisible to every profile, it is invisible + here too, and the manager finds out before an allocator does. + """ + record = _record(db_session, slug) + payload = project( + profile=Profile.FULL_PLUS_POSITIONS, + **_projection_inputs(db_session, record), + ) + payload["state"] = "owner" + return payload + + +@app.get("/api/records/{slug}/disclosure") +def disclosure_matrix(slug: str, db_session: SessionDep, _: ScopeDep) -> dict[str, Any]: + """What each profile would actually put on the wire, measured. + + The manager's real question is not "is it hidden in the UI" — it is "did it + leave the building". So this runs the projection once per profile and + reports the serialized size and the top-level keys that survived. A section + missing from `keys` is a section that does not exist in that response. + """ + record = _record(db_session, slug) + inputs = _projection_inputs(db_session, record) + + rungs = [] + for profile in Profile: + payload = project(profile=profile, **inputs) + body = json.dumps(payload, default=str) + metrics = sorted({metric["key"] for metric in payload.get("metrics", [])}) + rungs.append( + { + "profile": profile.slug, + "rank": int(profile), + "bytes": len(body.encode("utf-8")), + "keys": sorted(payload.keys()), + "metric_keys": metrics, + "counts": { + "metrics": len(payload.get("metrics", [])), + "evidence": len(payload.get("evidence", [])), + "findings": len(payload.get("findings", [])), + "nav_series": len(payload.get("nav_series", [])), + "positions": len(payload.get("positions", [])), + "merkle_leaves": len(payload.get("merkle_leaves", []) or []), + }, + } + ) + + return { + "record": {"slug": record.slug, "name": record.name}, + "profiles": rungs, + # Every section any profile can carry, so the console can draw a matrix + # with a row per section rather than guessing from the widest rung. + "sections": sorted({key for rung in rungs for key in rung["keys"]}), + } + + +@app.get("/api/records/{slug}/preview/{profile}") +def preview_as( + slug: str, profile: str, db_session: SessionDep, _: ScopeDep +) -> dict[str, Any]: + """The exact bytes an allocator on this profile would receive. + + Not a mock-up of the allocator view — the same projection, so what the + manager previews and what the allocator gets cannot drift apart. + """ + record = _record(db_session, slug) + try: + rung = Profile.from_slug(profile) + except ValueError as exc: + raise HTTPException(422, str(exc)) from None + + payload = project(profile=rung, **_projection_inputs(db_session, record)) + payload["state"] = "open" + payload["viewer_email"] = "preview@owner" + return payload + + +@app.get("/api/records/{slug}/invites") +def list_invites(slug: str, db_session: SessionDep, _: ScopeDep) -> list[dict[str, Any]]: + """Who holds a key to this record, and what it opens. + + The token itself is unrecoverable — only its hash was stored — so this is a + list of standing permissions, not a list of links to re-send. + """ + record = _record(db_session, slug) + invites = db_session.scalars( + select(db.Invite) + .where(db.Invite.record_id == record.id) + .order_by(db.Invite.created_at) + ).all() + if not invites: + return [] + + events = db_session.scalars( + select(db.AccessEvent) + .where(db.AccessEvent.invite_id.in_([invite.id for invite in invites])) + .order_by(db.AccessEvent.at) + ).all() + views: dict[str, list[db.AccessEvent]] = {} + for event in events: + views.setdefault(event.invite_id, []).append(event) + + now = datetime.now(timezone.utc) + rows = [] + for invite in invites: + expires = invite.expires_at + if expires.tzinfo is None: + expires = expires.replace(tzinfo=timezone.utc) + seen = views.get(invite.id, []) + if invite.revoked_at is not None: + status = "revoked" + elif expires < now: + status = "expired" + else: + status = "active" + rows.append( + { + "id": invite.id, + "viewer_email": invite.viewer_email, + "profile": invite.profile, + "status": status, + "nda_accepted_at": invite.nda_accepted_at.isoformat() + if invite.nda_accepted_at + else None, + "expires_at": expires.isoformat(), + "created_at": invite.created_at.isoformat(), + "views": len([event for event in seen if event.action == "view"]), + "last_seen_at": seen[-1].at.isoformat() if seen else None, + } + ) + return rows + + +@app.get("/api/records/{slug}/access-log") +def access_log(slug: str, db_session: SessionDep, _: ScopeDep) -> list[dict[str, Any]]: + record = _record(db_session, slug) + invites = db_session.scalars( + select(db.Invite).where(db.Invite.record_id == record.id) + ).all() + by_id = {invite.id: invite for invite in invites} + if not by_id: + return [] + events = db_session.scalars( + select(db.AccessEvent) + .where(db.AccessEvent.invite_id.in_(list(by_id))) + .order_by(db.AccessEvent.at) + ).all() + return [ + { + "at": event.at.isoformat(), + "action": event.action, + "viewer_email": by_id[event.invite_id].viewer_email, + "profile": by_id[event.invite_id].profile, + "ip": event.ip, + "chain_tx": event.chain_tx, + "explorer_url": settings().explorer_tx(event.chain_tx) + if event.chain_tx + else None, + } + for event in events + ] + + +# -- the fence -------------------------------------------------------------- + + +def _invite(db_session: Session, token: str) -> db.Invite: + invite = db_session.scalar( + select(db.Invite).where(db.Invite.token_hash == token_hash(token)) + ) + if invite is None: + raise HTTPException(404, "no such invite") + now = datetime.now(timezone.utc) + if invite.revoked_at is not None: + raise HTTPException(403, "this invite has been revoked") + expires = invite.expires_at + if expires.tzinfo is None: + expires = expires.replace(tzinfo=timezone.utc) + if expires < now: + raise HTTPException(403, "this invite has expired") + return invite + + +@app.get("/api/view/{token}") +def view(token: str, request: Request, db_session: SessionDep) -> dict[str, Any]: + """The NDA gate renders before anything else. + + Until consent is recorded, this returns the terms and the record's name. + No metric, no tier, no evidence — not filtered out downstream, simply never + assembled. + """ + invite = _invite(db_session, token) + record = db_session.get(db.Record, invite.record_id) + assert record is not None + + if invite.nda_accepted_at is None: + return { + "state": "nda_required", + "record": {"name": record.name, "slug": record.slug}, + "viewer_email": invite.viewer_email, + "profile": invite.profile, + "nda": {"version": NDA_VERSION, "text": NDA_TEXT}, + } + + inputs = _projection_inputs(db_session, record) + + _record_access(db_session, invite, record, "view", request) + + payload = project( + profile=Profile.from_slug(invite.profile), + **inputs, + watermark={ + "viewer": invite.viewer_email, + "at": datetime.now(timezone.utc).isoformat(), + "record": record.slug, + }, + ) + payload["state"] = "open" + payload["viewer_email"] = invite.viewer_email + return payload + + +class NdaIn(BaseModel): + accept: bool = True + + +@app.post("/api/view/{token}/nda") +def accept_nda( + token: str, body: NdaIn, request: Request, db_session: SessionDep +) -> dict[str, Any]: + """Consent is written before the first byte of data is served.""" + invite = _invite(db_session, token) + if not body.accept: + raise HTTPException(400, "the NDA must be accepted to continue") + + record = db_session.get(db.Record, invite.record_id) + assert record is not None + + if invite.nda_accepted_at is None: + invite.nda_accepted_at = datetime.now(timezone.utc) + db_session.flush() + + event = _record_access(db_session, invite, record, "nda_accepted", request) + return { + "state": "accepted", + "at": invite.nda_accepted_at.isoformat(), + "chain_tx": event.chain_tx, + "explorer_url": settings().explorer_tx(event.chain_tx) if event.chain_tx else None, + } + + +@app.get("/api/view/{token}/proof/{digest}") +def proof(token: str, digest: str, db_session: SessionDep) -> dict[str, Any]: + """A Merkle path the browser can re-fold on its own. + + Only for leaves the profile permits. Handing over a proof for a position + leaf would leak the leaf's hash to a viewer who is not allowed to see the + position, which is a smaller leak than the position itself but still a leak. + """ + invite = _invite(db_session, token) + if invite.nda_accepted_at is None: + raise HTTPException(403, "the NDA has not been accepted") + + profile = Profile.from_slug(invite.profile) + if profile < Profile.FULL_DETAIL: + raise HTTPException(403, "this disclosure profile does not include proofs") + + snapshot = db_session.scalar( + select(db.Snapshot) + .where(db.Snapshot.record_id == invite.record_id) + .order_by(db.Snapshot.seq.desc()) + .limit(1) + ) + if snapshot is None: + raise HTTPException(404, "no snapshot") + + leaves = [bytes.fromhex(leaf["digest"]) for leaf in snapshot.leaves or []] + wanted = digest.removeprefix("0x").lower() + if wanted not in {leaf.hex() for leaf in leaves}: + raise HTTPException(404, "no such leaf in this snapshot") + + tree = MerkleTree(leaves) + merkle_proof = tree.proof_for(bytes.fromhex(wanted)) + return { + "root": "0x" + tree.root.hex(), + "seq": snapshot.seq, + **merkle_proof.as_dict(), + } + + +@app.get("/api/view/{token}/document/{sha256}") +def download_document( + token: str, sha256: str, request: Request, db_session: SessionDep +) -> Response: + """The original signed bytes, so a viewer can run DKIM themselves. + + This is the route that makes our API not the trust path. Whoever holds the + .eml and the captured DNS record does not need us at all. + """ + invite = _invite(db_session, token) + if invite.nda_accepted_at is None: + raise HTTPException(403, "the NDA has not been accepted") + if Profile.from_slug(invite.profile) < Profile.FULL_DETAIL: + raise HTTPException(403, "this disclosure profile does not include documents") + + document = db_session.scalar( + select(db.Document).where( + db.Document.sha256 == sha256, db.Document.record_id == invite.record_id + ) + ) + if document is None: + raise HTTPException(404, "no such document in this record") + + record = db_session.get(db.Record, invite.record_id) + assert record is not None + _record_access(db_session, invite, record, f"download:{sha256[:12]}", request) + + raw = blob_store().get(document.blob_key) + return Response( + content=raw, + media_type="message/rfc822", + headers={ + "Content-Disposition": f'attachment; filename="{document.sha256[:16]}.eml"' + }, + ) + + +@app.get("/api/view/{token}/bundle") +def bundle(token: str, request: Request, db_session: SessionDep) -> Response: + """Everything needed to prove this record without us. + + The .eml files, the DNS keys as captured at first verification, the Merkle + leaves and proofs, and the contract address. Feed the zip to + `podarena-verify bundle` and every check runs offline except the on-chain + root, which you should check against an RPC we do not control. + + Exporting this is the point of the product. A record you can only verify by + asking us has not been verified. + """ + import io + import zipfile + + invite = _invite(db_session, token) + if invite.nda_accepted_at is None: + raise HTTPException(403, "the NDA has not been accepted") + if Profile.from_slug(invite.profile) < Profile.FULL_DETAIL: + raise HTTPException(403, "this disclosure profile does not include the bundle") + + record = db_session.get(db.Record, invite.record_id) + assert record is not None + snapshot = db_session.scalar( + select(db.Snapshot) + .where(db.Snapshot.record_id == record.id) + .order_by(db.Snapshot.seq.desc()) + .limit(1) + ) + if snapshot is None: + raise HTTPException(404, "no snapshot to export") + + _record_access(db_session, invite, record, "bundle_export", request) + + documents = _documents(db_session, record.id) + leaves = snapshot.leaves or [] + tree = MerkleTree([bytes.fromhex(leaf["digest"]) for leaf in leaves]) + + captured: dict[str, str] = {} + manifest_documents: dict[str, Any] = {} + for document, verdict in documents: + manifest_documents[document.sha256] = { + "filename": document.filename, + "doc_type": document.doc_type, + "channel": document.channel, + "dkim_verified": bool(verdict and verdict.verified), + "d_domain": verdict.d_domain if verdict else None, + "selector": verdict.selector if verdict else None, + } + if verdict and verdict.dns_txt_record and verdict.selector and verdict.d_domain: + captured[f"{verdict.selector}._domainkey.{verdict.d_domain}"] = ( + verdict.dns_txt_record + ) + + cfg = settings() + manifest = { + "generated_by": "PodShop Arena", + "record": {"slug": record.slug, "name": record.name, "strategy": record.strategy}, + "snapshot": { + "seq": snapshot.seq, + "root": snapshot.root, + "tier": snapshot.tier, + "metrics_hash": snapshot.metrics_hash, + "methodology_version": snapshot.methodology_version, + "extractor_versions": snapshot.extractor_versions, + }, + "chain": { + "chain_id": cfg.chain_id, + "rpc_url": cfg.rpc_url, + "registry_address": cfg.registry_address, + "record_key": record.chain_key, + "explorer_url": cfg.explorer_address(cfg.registry_address) + if cfg.registry_address + else None, + }, + "documents": manifest_documents, + } + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("manifest.json", json.dumps(manifest, indent=2)) + archive.writestr("captured_dns.json", json.dumps(captured, indent=2, sort_keys=True)) + archive.writestr("leaves.json", json.dumps(leaves, indent=2)) + archive.writestr("HOW-TO-VERIFY.txt", _HOW_TO_VERIFY.strip() + "\n") + + for document, _ in documents: + name = document.filename or f"{document.sha256[:16]}.eml" + archive.writestr(f"documents/{name}", blob_store().get(document.blob_key)) + + for leaf in leaves: + merkle_proof = tree.proof_for(bytes.fromhex(leaf["digest"])) + archive.writestr( + f"proofs/{leaf['digest'][:16]}.json", + json.dumps( + {"root": snapshot.root, "label": leaf["label"], **merkle_proof.as_dict()}, + indent=2, + ), + ) + + return Response( + content=buffer.getvalue(), + media_type="application/zip", + headers={ + "Content-Disposition": ( + f'attachment; filename="{record.slug}-seq{snapshot.seq}.zip"' + ) + }, + ) + + +_HOW_TO_VERIFY = """ +This bundle proves a track record without PodShop Arena being involved. + + 1. Check the signatures. Every .eml in documents/ carries the institution's + own DKIM signature. captured_dns.json holds the public keys exactly as they + stood in DNS when we first verified them — which matters, because selectors + get retired and a signature nobody wrote the key down for becomes + unverifiable. + + 2. Check our reading of them. The extractor versions are in manifest.json. + Open any .eml and confirm the numbers are what we said they were. + + 3. Check the tree. proofs/ holds a Merkle path for every leaf. Each one folds + to the root in manifest.json. + + 4. Check the root against the chain. Use any RPC endpoint you like — ours is + deliberately not in the trust path. + +The quickest route: + + pip install podarena # or clone the repo + podarena-verify bundle . + +If it disagrees with anything PodShop Arena told you, believe this bundle. +""" + + +def _record_access( + db_session: Session, + invite: db.Invite, + record: db.Record, + action: str, + request: Request, +) -> db.AccessEvent: + """Commit the access on-chain, then write the audit row once. + + The chain call goes first because it has to. `access_event` is append-only + in Postgres, so the row must arrive complete — filling in `chain_tx` + afterwards is an UPDATE, and the trigger rejects it. + + That ordering costs less than it looks like it should. `log_access` never + raises; it catches and returns a failed receipt. So a network blip still + writes the row, with a null `chain_tx`, and we still know who looked. The + only thing we give up is a crash during the RPC itself. + """ + commitment = viewer_commitment(invite.viewer_email, record.viewer_salt) + receipt = chain_client().log_access( + record.chain_key, commitment, int(Profile.from_slug(invite.profile)) + ) + event = db.AccessEvent( + invite_id=invite.id, + action=action, + ip=request.client.host if request.client else None, + ua=request.headers.get("user-agent", "")[:255] or None, + detail={"profile": invite.profile}, + chain_tx=receipt.tx_hash or None, + ) + db_session.add(event) + db_session.flush() + return event + + +def _documents( + db_session: Session, record_id: str +) -> list[tuple[db.Document, db.DkimVerdictRow | None]]: + documents = db_session.scalars( + select(db.Document) + .where(db.Document.record_id == record_id) + .order_by(db.Document.received_at) + ).all() + pairs = [] + for document in documents: + verdict = db_session.scalar( + select(db.DkimVerdictRow).where( + db.DkimVerdictRow.document_id == document.id + ) + ) + pairs.append((document, verdict)) + return pairs + + +def _record(db_session: Session, slug: str) -> db.Record: + record = db_session.scalar(select(db.Record).where(db.Record.slug == slug)) + if record is None: + raise HTTPException(404, f"no record {slug}") + return record + + +# -- static frontend -------------------------------------------------------- + +WEB_DIST = Path(__file__).resolve().parent.parent / "web" / "dist" + +if WEB_DIST.exists(): + app.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets") + + @app.get("/{path:path}", include_in_schema=False) + def spa(path: str) -> FileResponse: + candidate = WEB_DIST / path + if path and candidate.is_file(): + return FileResponse(candidate) + return FileResponse(WEB_DIST / "index.html") diff --git a/api/projection.py b/api/projection.py new file mode 100644 index 0000000..3aea7d3 --- /dev/null +++ b/api/projection.py @@ -0,0 +1,230 @@ +"""Disclosure profiles, applied as a server-side projection. + +The response is *built up* from what a profile permits. It is never built in +full and then filtered down, because the filtering version fails open: add a +field to the full object, forget to add it to the deny-list, and it ships to +everyone. Here, a new field is invisible until someone writes the line that +includes it. + +That is the one security property to hold absolutely: hidden data does not cross +the wire. Not hidden with CSS, not omitted by a component, not gated on a flag +the client sends — absent from the serialized payload. +""" + +from __future__ import annotations + +from enum import IntEnum +from typing import Any, Iterable + +from adapters import db +from core.tiers import Tier + + +class Profile(IntEnum): + """Ordered by how much they reveal, so comparisons read naturally.""" + + SUMMARY = 0 + RATIOS_AND_RISK = 1 + FULL_DETAIL = 2 + FULL_PLUS_POSITIONS = 3 + + @property + def slug(self) -> str: + return self.name.lower() + + @classmethod + def from_slug(cls, slug: str) -> Profile: + try: + return cls[slug.upper()] + except KeyError: + raise ValueError(f"unknown disclosure profile: {slug!r}") from None + + +DEFAULT_PROFILE = Profile.SUMMARY + +# Headline performance. What an allocator sees before they have agreed to +# anything. +SUMMARY_METRICS = frozenset({"twr_cumulative", "twr_annualized", "modified_dietz"}) + +# Risk and ratios. A separate rung because these are what a competitor would +# most like to have, and they are useless without the headline anyway. +RISK_METRICS = frozenset( + { + "volatility_annualized", + "sharpe", + "sortino", + "calmar", + "max_drawdown", + "var_historical", + } +) + + +def permitted_metrics(profile: Profile) -> frozenset[str]: + if profile is Profile.SUMMARY: + return SUMMARY_METRICS + return SUMMARY_METRICS | RISK_METRICS + + +def project( + *, + profile: Profile, + record: db.Record, + snapshot: db.Snapshot, + metrics: Iterable[db.MetricRow], + findings: Iterable[db.FindingRow], + facts: Iterable[db.FactRow], + documents: Iterable[tuple[db.Document, db.DkimVerdictRow | None]], + anchor: db.Anchor | None, + watermark: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build exactly what this profile is allowed to see.""" + allowed = permitted_metrics(profile) + + payload: dict[str, Any] = { + "record": { + "slug": record.slug, + "name": record.name, + "strategy": record.strategy, + # keccak(slug). Public by construction — it is the key anyone needs + # to read this record's anchor off the chain without asking us. + "chain_key": record.chain_key, + }, + "profile": profile.slug, + # Nothing is displayed without its tier next to it. + "tier": snapshot.tier, + "tier_label": Tier.from_slug(snapshot.tier).label, + "tier_meaning": Tier.from_slug(snapshot.tier).meaning, + "snapshot": { + "seq": snapshot.seq, + "root": snapshot.root, + "metrics_hash": snapshot.metrics_hash, + "methodology_version": snapshot.methodology_version, + "extractor_versions": snapshot.extractor_versions, + "created_at": snapshot.created_at.isoformat(), + }, + "anchor": _anchor(anchor), + "metrics": [ + _metric(row) for row in metrics if row.key in allowed + ], + } + + if watermark is not None: + payload["watermark"] = watermark + + if profile >= Profile.FULL_DETAIL: + payload["findings"] = [ + { + "kind": row.kind, + "severity": row.severity, + "detail": row.detail, + "as_of": row.as_of.isoformat() if row.as_of else None, + } + for row in findings + ] + payload["evidence"] = [ + _document(document, verdict) for document, verdict in documents + ] + payload["nav_series"] = [ + { + "as_of": fact.as_of.isoformat(), + "amount_minor": fact.amount_minor, + "currency": fact.currency, + "tier": fact.tier, + } + for fact in facts + if fact.kind == "nav" + ] + payload["flows"] = [ + { + "as_of": fact.as_of.isoformat(), + "amount_minor": fact.amount_minor, + "currency": fact.currency, + "tier": fact.tier, + "note": fact.note, + } + for fact in facts + if fact.kind == "cash_flow" + ] + payload["merkle_leaves"] = snapshot.leaves + + if profile >= Profile.FULL_PLUS_POSITIONS: + # Positions are opt-in, per viewer, every time. This is the rung a + # manager is most afraid of, and it is the last one for that reason. + payload["positions"] = [ + { + "as_of": fact.as_of.isoformat(), + "kind": fact.kind, + "instrument": fact.instrument, + "quantity": str(fact.quantity) if fact.quantity is not None else None, + "price": str(fact.price) if fact.price is not None else None, + "amount_minor": fact.amount_minor, + "currency": fact.currency, + "tier": fact.tier, + } + for fact in facts + if fact.kind in ("trade", "position") + ] + + return payload + + +def _metric(row: db.MetricRow) -> dict[str, Any]: + return { + "key": row.key, + "basis": row.basis, + "value": str(row.value), + "unit": row.unit, + "formula": row.formula, + "tier": row.tier, + "inputs": row.inputs, + } + + +def _anchor(anchor: db.Anchor | None) -> dict[str, Any] | None: + if anchor is None: + return None + return { + "status": anchor.status, + "chain_id": anchor.chain_id, + "root": anchor.root, + "seq": anchor.seq, + "tx_hash": anchor.tx_hash, + "block_number": anchor.block_number, + "confirmed_at": anchor.confirmed_at.isoformat() if anchor.confirmed_at else None, + "error": anchor.error, + } + + +def _document(document: db.Document, verdict: db.DkimVerdictRow | None) -> dict[str, Any]: + """Evidence, with the captured key that keeps it checkable. + + `dns_txt_record` is deliberately included at full detail: a viewer who has + the .eml and this record can verify the signature themselves, with no + access to our API and no working DNS. That is the whole point. + """ + entry: dict[str, Any] = { + "sha256": document.sha256, + "filename": document.filename, + "doc_type": document.doc_type, + "channel": document.channel, + "institution_id": document.institution_id, + "byte_length": document.byte_length, + "received_at": document.received_at.isoformat(), + } + if verdict is not None: + entry["dkim"] = { + "verified": verdict.verified, + "d_domain": verdict.d_domain, + "selector": verdict.selector, + "algo": verdict.algo, + "l_tag_present": verdict.l_tag_present, + "body_hash_matched": verdict.body_hash_matched, + "dns_txt_record": verdict.dns_txt_record, + "dns_captured_at": verdict.dns_captured_at.isoformat() + if verdict.dns_captured_at + else None, + "failure_reason": verdict.failure_reason, + "signatures": verdict.signature_summaries, + } + return entry diff --git a/contracts/.gitmodules b/contracts/.gitmodules new file mode 100644 index 0000000..888d42d --- /dev/null +++ b/contracts/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/contracts/foundry.lock b/contracts/foundry.lock new file mode 100644 index 0000000..31b0fe2 --- /dev/null +++ b/contracts/foundry.lock @@ -0,0 +1,8 @@ +{ + "lib/forge-std": { + "tag": { + "name": "v1.16.2", + "rev": "bf647bd6046f2f7da30d0c2bf435e5c76a780c1b" + } + } +} \ No newline at end of file diff --git a/contracts/foundry.toml b/contracts/foundry.toml new file mode 100644 index 0000000..f8b66df --- /dev/null +++ b/contracts/foundry.toml @@ -0,0 +1,14 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +test = "test" +solc_version = "0.8.24" +optimizer = true +optimizer_runs = 200 + +[rpc_endpoints] +monad_testnet = "${PODARENA_RPC_URL}" + +[etherscan] +monad_testnet = { key = "unused", url = "https://testnet.monadvision.com/api" } diff --git a/contracts/script/Deploy.s.sol b/contracts/script/Deploy.s.sol new file mode 100644 index 0000000..817ba00 --- /dev/null +++ b/contracts/script/Deploy.s.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Script, console} from "forge-std/Script.sol"; +import {TrackRecordRegistry} from "../src/TrackRecordRegistry.sol"; + +/// Deploy to Monad Testnet (chain 10143): +/// +/// export PODARENA_RPC_URL=https://testnet-rpc.monad.xyz +/// export PODARENA_ANCHOR_PRIVATE_KEY=0x... +/// forge script script/Deploy.s.sol:Deploy \ +/// --rpc-url $PODARENA_RPC_URL --broadcast +/// +/// Fund the deployer at https://faucet.monad.xyz first. +contract Deploy is Script { + function run() external returns (TrackRecordRegistry registry) { + uint256 key = vm.envUint("PODARENA_ANCHOR_PRIVATE_KEY"); + + vm.startBroadcast(key); + registry = new TrackRecordRegistry(); + vm.stopBroadcast(); + + console.log("TrackRecordRegistry:", address(registry)); + console.log("chain id:", block.chainid); + console.log("set PODARENA_REGISTRY_ADDRESS to the address above"); + } +} diff --git a/contracts/src/TrackRecordRegistry.sol b/contracts/src/TrackRecordRegistry.sol new file mode 100644 index 0000000..5cf3ae3 --- /dev/null +++ b/contracts/src/TrackRecordRegistry.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title TrackRecordRegistry +/// @notice Commitments to track-record snapshots, and a log of who looked. +/// +/// Storage holds only the head and a counter. Every historical root lives in +/// events, which cost a fraction of storage and are just as verifiable to +/// anyone reading the log — the chain does not need to answer "what was the +/// root in March" from state when it can prove it from history. +/// +/// `seq` is strictly monotonic per record. That is the whole anti-backfill +/// mechanism: a manager who quietly drops a bad month has to leave either a gap +/// in a public sequence or a superseded root that anyone can still read. It +/// only means something if the sequence cannot be tampered with, so the first +/// anchor claims the record and only that address can advance it. +/// +/// Why a chain at all, and why this one. We anchor on every ingest and every +/// view, which is only sensible at 0.3s blocks and 0.6s finality. On a +/// twelve-second chain you batch nightly, and the moment you batch you have +/// proved the day rather than the moment — and the moment is the property worth +/// having. +contract TrackRecordRegistry { + struct Head { + bytes32 root; + uint64 seq; + uint64 ts; + } + + mapping(bytes32 => Head) private _heads; + + /// @notice Who may advance a record. Set by the first anchor, forever. + mapping(bytes32 => address) public custodian; + + event Anchored( + bytes32 indexed recordId, + bytes32 root, + uint64 seq, + uint64 ts, + address indexed by + ); + + /// @notice A view happened. `viewerCommitment` is keccak(viewerId ‖ salt), + /// so the chain shows that someone looked and when, without publishing who. + /// The salt goes to the record owner alone. + event Accessed( + bytes32 indexed recordId, + bytes32 viewerCommitment, + uint8 profile, + uint64 ts + ); + + error NotCustodian(bytes32 recordId, address caller, address expected); + error EmptyRoot(); + + /// @notice Commit a new root. Returns the sequence number it was given. + function anchor(bytes32 recordId, bytes32 root) external returns (uint64 seq) { + if (root == bytes32(0)) revert EmptyRoot(); + + address holder = custodian[recordId]; + if (holder == address(0)) { + custodian[recordId] = msg.sender; + } else if (holder != msg.sender) { + revert NotCustodian(recordId, msg.sender, holder); + } + + Head storage head_ = _heads[recordId]; + unchecked { + seq = head_.seq + 1; + } + head_.root = root; + head_.seq = seq; + // casting to 'uint64' is safe because a unix timestamp does not exceed + // 2^64 seconds for another 584 billion years + // forge-lint: disable-next-line(unsafe-typecast) + head_.ts = uint64(block.timestamp); + + // forge-lint: disable-next-line(unsafe-typecast) + emit Anchored(recordId, root, seq, uint64(block.timestamp), msg.sender); + } + + /// @notice Record that a viewer opened a record under a disclosure profile. + /// Deliberately unpermissioned to write: an access log that only the + /// platform can append to is an access log the platform can decline to + /// append to. + function logAccess(bytes32 recordId, bytes32 viewerCommitment, uint8 profile) + external + { + // forge-lint: disable-next-line(unsafe-typecast) + emit Accessed(recordId, viewerCommitment, profile, uint64(block.timestamp)); + } + + /// @notice The current commitment for a record. + function head(bytes32 recordId) + external + view + returns (bytes32 root, uint64 seq, uint64 ts) + { + Head storage head_ = _heads[recordId]; + return (head_.root, head_.seq, head_.ts); + } +} diff --git a/contracts/test/TrackRecordRegistry.t.sol b/contracts/test/TrackRecordRegistry.t.sol new file mode 100644 index 0000000..901f127 --- /dev/null +++ b/contracts/test/TrackRecordRegistry.t.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {TrackRecordRegistry} from "../src/TrackRecordRegistry.sol"; + +contract TrackRecordRegistryTest is Test { + TrackRecordRegistry internal registry; + + bytes32 internal constant RECORD = keccak256("meridian-global-macro"); + address internal constant MANAGER = address(0xA11CE); + address internal constant STRANGER = address(0xBAD); + + event Anchored( + bytes32 indexed recordId, bytes32 root, uint64 seq, uint64 ts, address indexed by + ); + + function setUp() public { + registry = new TrackRecordRegistry(); + } + + function test_headStartsEmpty() public view { + (bytes32 root, uint64 seq, uint64 ts) = registry.head(RECORD); + assertEq(root, bytes32(0)); + assertEq(seq, 0); + assertEq(ts, 0); + } + + function test_firstAnchorClaimsAndStartsAtOne() public { + vm.prank(MANAGER); + uint64 seq = registry.anchor(RECORD, keccak256("root-1")); + + assertEq(seq, 1); + assertEq(registry.custodian(RECORD), MANAGER); + + (bytes32 root, uint64 stored, ) = registry.head(RECORD); + assertEq(root, keccak256("root-1")); + assertEq(stored, 1); + } + + /// The sequence is the anti-backfill mechanism, so it has to be strictly + /// monotonic with no way to reset it. + function test_sequenceIsStrictlyMonotonic() public { + vm.startPrank(MANAGER); + for (uint256 i = 1; i <= 5; i++) { + uint64 seq = registry.anchor(RECORD, keccak256(abi.encode("root", i))); + assertEq(seq, uint64(i)); + } + vm.stopPrank(); + + (, uint64 head, ) = registry.head(RECORD); + assertEq(head, 5); + } + + /// If anyone could advance somebody else's record, the sequence would prove + /// nothing — a griefer could manufacture the gaps that are supposed to be + /// evidence of tampering. + function test_strangerCannotAdvanceSomeoneElsesRecord() public { + vm.prank(MANAGER); + registry.anchor(RECORD, keccak256("root-1")); + + vm.prank(STRANGER); + vm.expectRevert( + abi.encodeWithSelector( + TrackRecordRegistry.NotCustodian.selector, RECORD, STRANGER, MANAGER + ) + ); + registry.anchor(RECORD, keccak256("hostile")); + } + + function test_emptyRootRejected() public { + vm.expectRevert(TrackRecordRegistry.EmptyRoot.selector); + registry.anchor(RECORD, bytes32(0)); + } + + function test_anchorEmitsHistory() public { + vm.warp(1_800_000_000); + vm.expectEmit(true, true, true, true); + emit Anchored(RECORD, keccak256("root-1"), 1, uint64(1_800_000_000), MANAGER); + + vm.prank(MANAGER); + registry.anchor(RECORD, keccak256("root-1")); + } + + /// Two records are independent; one manager's sequence cannot disturb another's. + function test_recordsAreIndependent() public { + bytes32 other = keccak256("other-record"); + + vm.prank(MANAGER); + registry.anchor(RECORD, keccak256("a")); + vm.prank(STRANGER); + registry.anchor(other, keccak256("b")); + + (, uint64 first, ) = registry.head(RECORD); + (, uint64 second, ) = registry.head(other); + assertEq(first, 1); + assertEq(second, 1); + assertEq(registry.custodian(other), STRANGER); + } + + function test_accessLogIsOpenToWrite() public { + // Deliberately unpermissioned: a log only we can append to is a log we + // can decline to append to. + vm.prank(STRANGER); + registry.logAccess(RECORD, keccak256("viewer-commitment"), 1); + } + + function testFuzz_anchorAlwaysAdvancesByOne(bytes32[8] calldata roots) public { + vm.startPrank(MANAGER); + uint64 expected = 0; + for (uint256 i = 0; i < roots.length; i++) { + if (roots[i] == bytes32(0)) continue; + expected++; + assertEq(registry.anchor(RECORD, roots[i]), expected); + } + vm.stopPrank(); + } +} diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..d52c67d --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,8 @@ +"""Pure domain core. No I/O, no clock, no network — that is what makes results +reproducible and the determinism testable. + +If you find yourself importing `requests`, `datetime.now`, or a database session +in here, the thing you are writing belongs in `adapters/`. +""" + +METHODOLOGY_VERSION = "1.0.0" diff --git a/core/canonical.py b/core/canonical.py new file mode 100644 index 0000000..ee6c179 --- /dev/null +++ b/core/canonical.py @@ -0,0 +1,135 @@ +"""Canonical serialization. The determinism contract starts here. + +Every hash in the system is taken over bytes produced by this module, so two +machines that disagree about how to write a number would produce two different +Merkle roots for identical evidence — and the anchor would be worthless. Hence +the rules: sorted keys, no insignificant whitespace, Decimals pinned to a fixed +scale, timestamps normalized to UTC, and a flat refusal to serialize any type we +have not thought about. Especially float. +""" + +from __future__ import annotations + +import hashlib +from datetime import date, datetime, timezone +from decimal import Context, Decimal, ROUND_HALF_EVEN +from enum import Enum +from typing import Any + +# Every Decimal is written at exactly this scale, so Decimal("1.1") and +# Decimal("1.10") — equal numbers, different objects — hash identically. +CANONICAL_EXPONENT = Decimal("1E-12") +_CTX = Context(prec=60, rounding=ROUND_HALF_EVEN) + +_LEAF_DOMAIN = b"podarena.leaf.v1" + + +class NotCanonical(TypeError): + """A value reached the serializer that has no single obvious byte form.""" + + +def canonical_decimal(value: Decimal) -> str: + if not isinstance(value, Decimal): + raise NotCanonical(f"expected Decimal, got {type(value).__name__}") + if not value.is_finite(): + raise NotCanonical(f"cannot canonicalize non-finite Decimal: {value}") + quantized = _CTX.quantize(value, CANONICAL_EXPONENT) + if quantized == 0: + # Decimal keeps a sign on zero; "-0.000000000000" and "0.000000000000" + # are the same number and must be the same bytes. + quantized = abs(quantized) + return format(quantized, "f") + + +def canonical_datetime(value: datetime) -> str: + if value.tzinfo is None: + raise NotCanonical( + "naive datetime has no canonical form; attach a timezone at the edge" + ) + return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ") + + +def _encode(value: Any) -> str: + if value is None: + return "null" + if value is True: + return "true" + if value is False: + return "false" + if isinstance(value, float): + raise NotCanonical( + "float is not serializable here; use Decimal for ratios and " + "integer minor units for money" + ) + if isinstance(value, int): + return str(value) + if isinstance(value, Decimal): + return f'"{canonical_decimal(value)}"' + if isinstance(value, str): + return _encode_string(value) + if isinstance(value, (bytes, bytearray)): + return f'"{bytes(value).hex()}"' + if isinstance(value, datetime): + return f'"{canonical_datetime(value)}"' + if isinstance(value, date): + return f'"{value.isoformat()}"' + if isinstance(value, Enum): + # Enum members serialize by name, so renumbering an enum can never + # silently change a historical hash. + return _encode_string(value.name.lower()) + if isinstance(value, dict): + for key in value: + if not isinstance(key, str): + raise NotCanonical(f"object keys must be str, got {type(key).__name__}") + items = ",".join( + f"{_encode_string(k)}:{_encode(value[k])}" for k in sorted(value) + ) + return "{" + items + "}" + if isinstance(value, (list, tuple)): + return "[" + ",".join(_encode(v) for v in value) + "]" + if hasattr(value, "canonical_form"): + return _encode(value.canonical_form()) + raise NotCanonical(f"no canonical form defined for {type(value).__name__}") + + +_ESCAPES = { + '"': '\\"', + "\\": "\\\\", + "\n": "\\n", + "\r": "\\r", + "\t": "\\t", + "\b": "\\b", + "\f": "\\f", +} + + +def _encode_string(value: str) -> str: + out = ['"'] + for ch in value: + if ch in _ESCAPES: + out.append(_ESCAPES[ch]) + elif ord(ch) < 0x20: + out.append(f"\\u{ord(ch):04x}") + else: + out.append(ch) + out.append('"') + return "".join(out) + + +def canonical(value: Any) -> bytes: + """The one true byte form of a value.""" + return _encode(value).encode("utf-8") + + +def sha256_hex(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def leaf_hash(kind: str, value: Any) -> bytes: + """Hash a value as a Merkle leaf of a named kind. + + The kind is mixed in so a document hash can never be mistaken for a fact + hash, even if the two happened to serialize to the same bytes. + """ + payload = _LEAF_DOMAIN + b"\x00" + kind.encode("ascii") + b"\x00" + canonical(value) + return hashlib.sha256(payload).digest() diff --git a/core/dkim/__init__.py b/core/dkim/__init__.py new file mode 100644 index 0000000..160db92 --- /dev/null +++ b/core/dkim/__init__.py @@ -0,0 +1,24 @@ +"""DKIM: the institution's own key vouching for bytes it signed years ago +without thinking about it. + +Split deliberately into `parse` (what does this message claim?) and `verify` +(is the claim true, given this key?), with the DNS lookup living outside both. +""" + +from core.dkim.parse import ( + SignatureInfo, + parse_tags, + required_dns_names, + signatures, +) +from core.dkim.verify import DkimAnalysis, SignatureSummary, verify_message + +__all__ = [ + "DkimAnalysis", + "SignatureInfo", + "SignatureSummary", + "parse_tags", + "required_dns_names", + "signatures", + "verify_message", +] diff --git a/core/dkim/parse.py b/core/dkim/parse.py new file mode 100644 index 0000000..b969396 --- /dev/null +++ b/core/dkim/parse.py @@ -0,0 +1,146 @@ +"""Reading DKIM-Signature headers out of raw message bytes. + +Headers are parsed by hand rather than through `email.parser` on purpose. The +whole proof rests on the bytes being exactly what arrived — CRLF and all — and +the cheapest way to guarantee we never hand a re-serialized message to the +verifier is to never build one. This module only ever *reads*. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class SignatureInfo: + """One DKIM-Signature header, unpacked but not judged.""" + + index: int + domain: str | None # d= + selector: str | None # s= + algorithm: str | None # a= + body_length: int | None # l= -- the dangerous one + body_hash: str | None # bh= + signed_headers: tuple[str, ...] # h= + canonicalization: str | None # c= + tags: dict[str, str] + + @property + def dns_name(self) -> str | None: + if not self.selector or not self.domain: + return None + return f"{self.selector}._domainkey.{self.domain}" + + +def split_header_block(raw: bytes) -> bytes: + """Everything up to (not including) the blank line that ends the headers.""" + for terminator in (b"\r\n\r\n", b"\n\n"): + idx = raw.find(terminator) + if idx != -1: + return raw[:idx] + return raw + + +def unfold_headers(raw: bytes) -> list[tuple[str, str]]: + """Return (name, value) pairs with continuation lines joined. + + RFC 5322 folding puts a header's value across several lines, each + continuation starting with whitespace. Unfolding is a read-only operation + on our copy; the stored blob is untouched. + """ + block = split_header_block(raw) + text = block.decode("utf-8", errors="replace") + lines = text.replace("\r\n", "\n").split("\n") + + headers: list[tuple[str, str]] = [] + current_name: str | None = None + current_value: list[str] = [] + + def flush() -> None: + if current_name is not None: + headers.append((current_name, "".join(current_value))) + + for line in lines: + if line[:1] in (" ", "\t") and current_name is not None: + current_value.append(line) + continue + flush() + if ":" in line: + name, _, value = line.partition(":") + current_name = name.strip() + current_value = [value] + else: + current_name = None + current_value = [] + flush() + return headers + + +def parse_tags(value: str) -> dict[str, str]: + """DKIM tag-value lists: `v=1; a=rsa-sha256; d=example.com; ...` + + Whitespace inside a value is stripped because folding may have inserted it + mid-base64 — that is normal and expected, not tampering. + """ + tags: dict[str, str] = {} + for part in value.split(";"): + part = part.strip() + if not part or "=" not in part: + continue + key, _, val = part.partition("=") + tags[key.strip()] = "".join(val.split()) + return tags + + +def signatures(raw: bytes) -> list[SignatureInfo]: + """Every DKIM-Signature on the message, in header order. + + Order matters: `dkimpy` addresses signatures by index counted the same way. + """ + found: list[SignatureInfo] = [] + idx = 0 + for name, value in unfold_headers(raw): + if name.lower() != "dkim-signature": + continue + tags = parse_tags(value) + + body_length: int | None = None + if "l" in tags: + try: + body_length = int(tags["l"]) + except ValueError: + body_length = -1 # present but unparseable; still disqualifying + + signed = tuple( + h.strip().lower() for h in tags.get("h", "").split(":") if h.strip() + ) + + found.append( + SignatureInfo( + index=idx, + domain=(tags.get("d") or "").lower().rstrip(".") or None, + selector=tags.get("s") or None, + algorithm=tags.get("a") or None, + body_length=body_length, + body_hash=tags.get("bh") or None, + signed_headers=signed, + canonicalization=tags.get("c") or None, + tags=tags, + ) + ) + idx += 1 + return found + + +def required_dns_names(raw: bytes) -> list[str]: + """Which DNS records an adapter must fetch before we can verify anything. + + Parsing has to happen before the lookup, which is why verification is two + steps: plan the lookups here, resolve them in an adapter, then verify purely. + """ + names: list[str] = [] + for sig in signatures(raw): + name = sig.dns_name + if name and name not in names: + names.append(name) + return names diff --git a/core/dkim/verify.py b/core/dkim/verify.py new file mode 100644 index 0000000..2b505c7 --- /dev/null +++ b/core/dkim/verify.py @@ -0,0 +1,275 @@ +"""Verifying a signature without touching the network. + +The DNS record is an *argument*, not something this module goes and fetches. +That single choice buys three things: `core` stays pure and testable, the same +function verifies a live message and a five-year-old archived one, and an +allocator running our standalone verifier gets bit-identical behaviour from a +captured record with no DNS at all. + +Verification is therefore two steps. `parse.required_dns_names` says what to +look up, an adapter looks it up, and this function judges. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Mapping, Sequence + +import dkim as dkimpy + +from core.dkim.parse import SignatureInfo, signatures +from core.types import DkimVerdict, Institution + +# RFC 6376 requires From to be covered. A signature that leaves it out lets +# anyone re-address the mail and keep the signature intact. +REQUIRED_SIGNED_HEADERS = ("from",) + + +@dataclass(frozen=True, slots=True) +class SignatureSummary: + """What we made of one signature. Kept even for signatures we ignore, so + the UI can show *why* Gmail's signature on a forward bought nothing.""" + + index: int + domain: str | None + selector: str | None + algorithm: str | None + l_tag_present: bool + considered: bool + verified: bool + reason: str | None = None + + def as_dict(self) -> dict[str, object]: + return { + "index": self.index, + "domain": self.domain, + "selector": self.selector, + "algorithm": self.algorithm, + "l_tag_present": self.l_tag_present, + "considered": self.considered, + "verified": self.verified, + "reason": self.reason, + } + + +@dataclass(frozen=True, slots=True) +class DkimAnalysis: + verdict: DkimVerdict + institution: Institution | None + summaries: tuple[SignatureSummary, ...] + + @property + def verified(self) -> bool: + return self.verdict.verified + + +def _dnsfunc(record: str): + def lookup(name, timeout=5): # signature must match dkimpy's expectation + return record.encode("utf-8") + + return lookup + + +def _match_institution( + sig: SignatureInfo, institutions: Sequence[Institution] +) -> Institution | None: + if not sig.domain: + return None + for inst in institutions: + if inst.signs_for(sig.domain): + return inst + return None + + +def _attempt( + raw: bytes, sig: SignatureInfo, record: str +) -> tuple[bool, bool, str | None]: + """Run the actual cryptography. Returns (verified, body_hash_matched, reason).""" + try: + verifier = dkimpy.DKIM(raw, minkey=1024) + ok = verifier.verify(idx=sig.index, dnsfunc=_dnsfunc(record)) + except dkimpy.ValidationError as exc: + text = str(exc) + # dkimpy distinguishes these, and the difference matters to a reader: + # a body hash mismatch means the content changed after signing. + if "body hash" in text.lower(): + return False, False, f"body hash mismatch — content changed after signing ({text})" + return False, True, f"signature did not validate ({text})" + except dkimpy.KeyFormatError as exc: + return False, False, f"DNS key is not a usable DKIM record ({exc})" + except dkimpy.MessageFormatError as exc: + return False, False, f"message is not well-formed for verification ({exc})" + except dkimpy.DKIMException as exc: + return False, False, f"verification failed ({exc})" + + if ok: + return True, True, None + return False, False, "signature did not validate" + + +def verify_message( + raw: bytes, + *, + dns_records: Mapping[str, str], + institutions: Sequence[Institution], + captured_at: datetime, +) -> DkimAnalysis: + """Judge a message against captured DNS keys. + + `dns_records` maps `._domainkey.` to the TXT record as it + stood when we looked. Storing that record is what keeps this checkable after + the selector is retired, which is most of the reason the chain earns a place. + """ + sigs = signatures(raw) + if not sigs: + return DkimAnalysis( + verdict=_unverified("no DKIM-Signature header on this message"), + institution=None, + summaries=(), + ) + + summaries: list[SignatureSummary] = [] + candidates: list[tuple[SignatureInfo, Institution]] = [] + + for sig in sigs: + inst = _match_institution(sig, institutions) + if inst is None: + summaries.append( + SignatureSummary( + index=sig.index, + domain=sig.domain, + selector=sig.selector, + algorithm=sig.algorithm, + l_tag_present=sig.body_length is not None, + considered=False, + verified=False, + reason=( + f"{sig.domain or 'unknown domain'} is not a known institution; " + "this signature proves that party handled the mail and nothing more" + ), + ) + ) + else: + candidates.append((sig, inst)) + + if not candidates: + seen = ", ".join(sorted({s.domain or "?" for s in sigs})) + return DkimAnalysis( + verdict=_unverified( + f"no signature from a known institution domain (saw: {seen})" + ), + institution=None, + summaries=tuple(summaries), + ) + + fallback: DkimVerdict | None = None + fallback_inst: Institution | None = None + + for sig, inst in candidates: + l_present = sig.body_length is not None + reason: str | None = None + + if l_present: + # Hard fail, not a warning. With l= only the first N bytes of the + # body are signed and anything can be appended below it. + reason = ( + f"l= tag present (l={sig.body_length}): only the first bytes of the " + "body are signed, so content can be appended below the signature" + ) + elif missing := [ + h for h in REQUIRED_SIGNED_HEADERS if h not in sig.signed_headers + ]: + reason = f"signature does not cover required header(s): {', '.join(missing)}" + + record = dns_records.get(sig.dns_name or "") + + if reason is None and record is None: + reason = ( + f"no DNS key captured for {sig.dns_name} — the selector may have " + "been retired, which is why we capture the record at first sight" + ) + + if reason is not None: + summaries.append( + SignatureSummary( + index=sig.index, + domain=sig.domain, + selector=sig.selector, + algorithm=sig.algorithm, + l_tag_present=l_present, + considered=True, + verified=False, + reason=reason, + ) + ) + if fallback is None: + fallback = DkimVerdict( + verified=False, + d_domain=sig.domain, + selector=sig.selector, + algo=sig.algorithm, + l_tag_present=l_present, + dns_txt_record=record, + dns_captured_at=captured_at if record else None, + body_hash_matched=False, + signed_headers=sig.signed_headers, + failure_reason=reason, + ) + fallback_inst = inst + continue + + assert record is not None + verified, body_ok, failure = _attempt(raw, sig, record) + + summaries.append( + SignatureSummary( + index=sig.index, + domain=sig.domain, + selector=sig.selector, + algorithm=sig.algorithm, + l_tag_present=False, + considered=True, + verified=verified, + reason=failure, + ) + ) + + verdict = DkimVerdict( + verified=verified, + d_domain=sig.domain, + selector=sig.selector, + algo=sig.algorithm, + l_tag_present=False, + dns_txt_record=record, + dns_captured_at=captured_at, + body_hash_matched=body_ok, + signed_headers=sig.signed_headers, + failure_reason=failure, + ) + + if verified: + return DkimAnalysis( + verdict=verdict, institution=inst, summaries=tuple(summaries) + ) + if fallback is None: + fallback, fallback_inst = verdict, inst + + assert fallback is not None + return DkimAnalysis( + verdict=fallback, institution=fallback_inst, summaries=tuple(summaries) + ) + + +def _unverified(reason: str) -> DkimVerdict: + return DkimVerdict( + verified=False, + d_domain=None, + selector=None, + algo=None, + l_tag_present=False, + dns_txt_record=None, + dns_captured_at=None, + body_hash_matched=False, + failure_reason=reason, + ) diff --git a/core/extraction/__init__.py b/core/extraction/__init__.py new file mode 100644 index 0000000..138d0d1 --- /dev/null +++ b/core/extraction/__init__.py @@ -0,0 +1,15 @@ +from core.extraction.base import ( + ExtractedFact, + Extractor, + ExtractionResult, + Registry, + registry, +) + +__all__ = [ + "ExtractedFact", + "ExtractionResult", + "Extractor", + "Registry", + "registry", +] diff --git a/core/extraction/base.py b/core/extraction/base.py new file mode 100644 index 0000000..16840a7 --- /dev/null +++ b/core/extraction/base.py @@ -0,0 +1,135 @@ +"""Extraction: our reading of a document, kept strictly apart from the proof +that the document is genuine. + +DKIM proves the bytes left the institution. It says nothing about whether our +parser read `NAV: 41,207,338.22` correctly. So extractors are pure, versioned +functions, the version travels with every fact, and a viewer can always pull the +original signed document and check our reading against it. + +An extractor cannot know how strong its own evidence is, so it never sets a +tier. It emits `ExtractedFact`, and the pipeline binds the account and the tier +that the verify step assigned. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date +from decimal import Decimal +from typing import Any, Protocol, runtime_checkable + +from core.tiers import Tier +from core.types import DocType, Fact, FactKind + + +@dataclass(frozen=True, slots=True) +class ExtractedFact: + """A reading, before it has been told whose account it belongs to or how + much anybody should believe it.""" + + as_of: date + kind: FactKind + currency: str + instrument: str | None = None + quantity: Decimal | None = None + price: Decimal | None = None + amount_minor: int | None = None + note: str | None = None + + def bind( + self, *, account_id: str, document_sha256: str, tier: Tier, extractor_version: str + ) -> Fact: + return Fact( + account_id=account_id, + document_sha256=document_sha256, + as_of=self.as_of, + kind=self.kind, + tier=tier, + currency=self.currency, + instrument=self.instrument, + quantity=self.quantity, + price=self.price, + amount_minor=self.amount_minor, + extractor_version=extractor_version, + note=self.note, + ) + + +@dataclass(frozen=True, slots=True) +class ExtractionResult: + status: str # "ok" | "unsupported" | "failed" + facts: tuple[ExtractedFact, ...] = () + payload: dict[str, Any] = field(default_factory=dict) + error: str | None = None + + @property + def ok(self) -> bool: + return self.status == "ok" + + +@runtime_checkable +class Extractor(Protocol): + """`(institution, doc_type, version) -> Fact[]`, and nothing else. + + Pure by contract: no clock, no network, no database. Given the same bytes it + must return the same facts forever, because a fact's hash is committed + on-chain and re-deriving it must reproduce that hash exactly. + """ + + id: str + version: str + institution_id: str + doc_type: DocType + + def recognizes(self, raw: bytes) -> bool: ... + + def extract(self, raw: bytes) -> ExtractionResult: ... + + +class Registry: + """Extractors keyed by institution and document type. + + Versions are never overwritten in place. Fixing a parser means registering a + new version and re-deriving; the original evidence never changes and the old + root stays honest about what the old code read. + """ + + def __init__(self) -> None: + self._by_key: dict[tuple[str, DocType], Extractor] = {} + + def register(self, extractor: Extractor) -> Extractor: + key = (extractor.institution_id, extractor.doc_type) + if key in self._by_key: + existing = self._by_key[key] + raise ValueError( + f"{existing.id} already handles {key}; register a new doc_type " + "or supersede it explicitly" + ) + self._by_key[key] = extractor + return extractor + + def for_document( + self, institution_id: str | None, doc_type: DocType + ) -> Extractor | None: + if institution_id is None: + return None + return self._by_key.get((institution_id, doc_type)) + + def sniff(self, institution_id: str | None, raw: bytes) -> Extractor | None: + """Find an extractor that recognizes these bytes, when the document type + was not declared up front.""" + for (inst, _), extractor in self._by_key.items(): + if institution_id is not None and inst != institution_id: + continue + if extractor.recognizes(raw): + return extractor + return None + + def versions(self) -> dict[str, str]: + return {e.id: e.version for e in self._by_key.values()} + + def all(self) -> tuple[Extractor, ...]: + return tuple(self._by_key.values()) + + +registry = Registry() diff --git a/core/extractors/__init__.py b/core/extractors/__init__.py new file mode 100644 index 0000000..5a4f621 --- /dev/null +++ b/core/extractors/__init__.py @@ -0,0 +1,35 @@ +"""The institutions whose signatures we will believe, and the code that reads +what they send. + +Importing this module is what populates the extractor registry. The institution +list doubles as the DKIM allow-list: a `d=` outside it may be a perfectly valid +signature and still tell an allocator nothing, because it proves the forwarder +handled the mail rather than the broker vouching for the numbers. +""" + +from __future__ import annotations + +from core.extraction.base import registry +from core.extractors import ibkr, selfreported +from core.types import Institution + +INSTITUTIONS: tuple[Institution, ...] = (ibkr.INSTITUTION,) + +INSTITUTIONS_BY_ID: dict[str, Institution] = {inst.id: inst for inst in INSTITUTIONS} + + +def institution_for_domain(d_domain: str) -> Institution | None: + for inst in INSTITUTIONS: + if inst.signs_for(d_domain): + return inst + return None + + +__all__ = [ + "INSTITUTIONS", + "INSTITUTIONS_BY_ID", + "ibkr", + "institution_for_domain", + "registry", + "selfreported", +] diff --git a/core/extractors/ibkr.py b/core/extractors/ibkr.py new file mode 100644 index 0000000..1fa63ea --- /dev/null +++ b/core/extractors/ibkr.py @@ -0,0 +1,388 @@ +"""Interactive Brokers. + +Trade confirmations first, deliberately. Statements from most brokers are a +notification — "your statement is ready, log in" — with no numbers in the body, +and a signature over that sentence proves nothing about a track record. A trade +confirmation carries the fill in the body, so the institution's key is signing +over the actual figures. + +The statement extractor below exists because trades alone cannot produce a TWR: +you need a NAV series and the external flows between them. IBKR will embed both +in the mail body when HTML delivery is switched on, which is a setting the +manager has to turn on — and if they haven't, the flows arrive as CSV and the +tier drops to self_reported. That is the ladder working, not a bug. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +from typing import Sequence + +from core.extraction.base import ExtractedFact, ExtractionResult, registry +from core.extractors.mime import Table, body_parts, first_of, tables, text_from_html +from core.extractors.numbers import ( + normalize_header, + parse_date, + parse_decimal, + to_minor, +) +from core.types import DocType, FactKind, Institution + +INSTITUTION = Institution( + id="ibkr", + name="Interactive Brokers", + domains=("interactivebrokers.com", "ibkr.com"), + doc_types=(DocType.TRADE_CONFIRMATION, DocType.STATEMENT), +) + +_ACCOUNT_RE = re.compile(r"\bU\d{7,9}\b") +_BASE_CCY_RE = re.compile(r"base\s+currency\s*[:\-]?\s*([A-Z]{3})", re.I) + +# Bounded date shapes. An open-ended `(.+?)` here is how you end up dating a +# statement by the settle date of a deposit that happened to appear further +# down the page — which is exactly what the first version of this did. +_DATE_TOKEN = ( + r"(?:\d{4}-\d{2}-\d{2}" + r"|[A-Z][a-z]{2,8}\s+\d{1,2},\s*\d{4}" + r"|\d{1,2}\s+[A-Z][a-z]{2,8}\s+\d{4}" + r"|\d{1,2}/\d{1,2}/\d{4})" +) +_PERIOD_RE = re.compile( + rf"period\s*[:\-]?\s*({_DATE_TOKEN})\s*(?:-|–|—|to)\s*({_DATE_TOKEN})", re.I +) +_STATEMENT_DATE_RE = re.compile(rf"statement\s+date\s*[:\-]?\s*({_DATE_TOKEN})", re.I) + +# Column headers we know how to read, and everything each is called in the wild. +_ALIASES: dict[str, set[str]] = { + "symbol": {"symbol", "instrument", "underlying", "description"}, + "when": {"date/time", "date", "trade date", "date & time", "settle date"}, + "quantity": {"quantity", "qty", "shares"}, + "price": {"t. price", "t.price", "trade price", "price", "exec price"}, + "proceeds": {"proceeds", "gross amount", "amount", "net amount"}, + "fee": {"comm/fee", "comm/tax", "commission", "comm", "commission/fee", "fees"}, + "currency": {"currency", "ccy", "curr"}, +} + + +def _column_map(header_row: Sequence[str]) -> dict[str, int]: + found: dict[str, int] = {} + for idx, cell in enumerate(header_row): + name = normalize_header(cell) + for field, aliases in _ALIASES.items(): + if name in aliases and field not in found: + found[field] = idx + return found + + +def _find_table( + all_tables: list[Table], *required: str, exclude: tuple[str, ...] = () +) -> tuple[Table, dict[str, int]] | None: + """Locate a table by what its header row promises, not by position. + + Position-based parsing breaks the first time the institution adds a column, + and it breaks silently, which is the worst way for a parser to break. + + `exclude` is how trades and flows are told apart. Both have a date and an + amount; only trades have a quantity. Discriminating on a column that must be + absent is sturdier than guessing from the values in the rows. + """ + for table in all_tables: + for row in table[:3]: # header is at or near the top + columns = _column_map(row) + if all(name in columns for name in required) and not any( + name in columns for name in exclude + ): + header_index = table.index(row) + return table[header_index + 1 :], columns + return None + + +def _cell(row: Sequence[str], columns: dict[str, int], name: str) -> str | None: + idx = columns.get(name) + if idx is None or idx >= len(row): + return None + value = row[idx].strip() + return value or None + + +def _is_noise(row: Sequence[str], columns: dict[str, int]) -> bool: + """Subtotals, section breaks and blank spacers all look like data rows.""" + if not any(cell.strip() for cell in row): + return True + symbol = _cell(row, columns, "symbol") or "" + if symbol.lower().startswith(("total", "subtotal", "sub-total")): + return True + first = row[0].strip().lower() if row else "" + return first.startswith(("total", "subtotal")) + + +def _document_text(raw: bytes) -> tuple[str, list[Table]]: + parts = body_parts(raw) + html = first_of(parts, "text/html") + if html: + return text_from_html(html), tables(html) + plain = first_of(parts, "text/plain") or "" + return plain, _text_tables(plain) + + +def _text_tables(plain: str) -> list[Table]: + """Plain-text confirmations arrive as pipe-delimited tables.""" + rows: Table = [] + for line in plain.splitlines(): + if line.count("|") < 2: + if rows: + break + continue + rows.append([cell.strip() for cell in line.strip().strip("|").split("|")]) + return [rows] if rows else [] + + +def _context(text: str) -> tuple[str, str | None]: + currency_match = _BASE_CCY_RE.search(text) + currency = currency_match.group(1).upper() if currency_match else "USD" + account_match = _ACCOUNT_RE.search(text) + return currency, account_match.group(0) if account_match else None + + +@dataclass(frozen=True, slots=True) +class IbkrTradeConfirmation: + """Fills, as signed by the broker. + + Each row becomes a TRADE fact and, when commission is charged, a separate + FEE fact — kept apart so gross and net can be computed from the same + evidence without either one being backed out of the other. + """ + + id: str = "ibkr.trade_confirmation" + version: str = "1.0.0" + institution_id: str = "ibkr" + doc_type: DocType = DocType.TRADE_CONFIRMATION + + def recognizes(self, raw: bytes) -> bool: + lowered = raw.lower() + looks_like_ibkr = b"interactive brokers" in lowered or b"ibkr" in lowered + return looks_like_ibkr and ( + b"trade confirmation" in lowered or b"proceeds" in lowered + ) + + def extract(self, raw: bytes) -> ExtractionResult: + try: + text, all_tables = _document_text(raw) + except Exception as exc: + return ExtractionResult(status="failed", error=f"could not read body: {exc}") + + found = _find_table(all_tables, "symbol", "quantity", "proceeds") + if found is None: + return ExtractionResult( + status="unsupported", + error="no trades table with symbol, quantity and proceeds columns", + ) + + rows, columns = found + default_currency, account_ref = _context(text) + + facts: list[ExtractedFact] = [] + skipped: list[str] = [] + + for row in rows: + if _is_noise(row, columns): + continue + + symbol = _cell(row, columns, "symbol") + quantity = parse_decimal(_cell(row, columns, "quantity")) + proceeds = parse_decimal(_cell(row, columns, "proceeds")) + as_of = parse_date(_cell(row, columns, "when")) + currency = (_cell(row, columns, "currency") or default_currency).upper() + + if not symbol or quantity is None or proceeds is None or as_of is None: + skipped.append(" | ".join(row)) + continue + + facts.append( + ExtractedFact( + as_of=as_of, + kind=FactKind.TRADE, + currency=currency, + instrument=symbol, + quantity=quantity, + price=parse_decimal(_cell(row, columns, "price")), + amount_minor=to_minor(proceeds, currency), + ) + ) + + fee = parse_decimal(_cell(row, columns, "fee")) + if fee is not None and fee != 0: + facts.append( + ExtractedFact( + as_of=as_of, + kind=FactKind.FEE, + currency=currency, + instrument=symbol, + amount_minor=to_minor(fee, currency), + note="commission and exchange fees", + ) + ) + + if not facts: + return ExtractionResult( + status="unsupported", + error="trades table found but no row parsed cleanly", + payload={"skipped_rows": skipped}, + ) + + return ExtractionResult( + status="ok", + facts=tuple(facts), + payload={ + "account_ref": account_ref, + "base_currency": default_currency, + "trade_rows": len(facts), + "skipped_rows": skipped, + }, + ) + + +@dataclass(frozen=True, slots=True) +class IbkrActivityStatement: + """NAV and external flows — the two series a TWR is actually made of. + + A return computed from NAV alone is wrong the moment money moves in or out, + so the flows matter as much as the levels and are extracted together or not + at all. + """ + + id: str = "ibkr.activity_statement" + version: str = "1.0.0" + institution_id: str = "ibkr" + doc_type: DocType = DocType.STATEMENT + + def recognizes(self, raw: bytes) -> bool: + lowered = raw.lower() + return (b"interactive brokers" in lowered or b"ibkr" in lowered) and ( + b"net asset value" in lowered or b"activity statement" in lowered + ) + + def extract(self, raw: bytes) -> ExtractionResult: + try: + text, all_tables = _document_text(raw) + except Exception as exc: + return ExtractionResult(status="failed", error=f"could not read body: {exc}") + + currency, account_ref = _context(text) + as_of = self._period_end(text) + if as_of is None: + return ExtractionResult( + status="unsupported", + error="no statement period or date found; a NAV without an as-of " + "date cannot be placed in a series", + ) + + facts: list[ExtractedFact] = [] + + nav = self._net_asset_value(all_tables) + if nav is not None: + facts.append( + ExtractedFact( + as_of=as_of, + kind=FactKind.NAV, + currency=currency, + amount_minor=to_minor(nav, currency), + note="total net asset value at period end", + ) + ) + + facts.extend(self._flows(all_tables, currency, as_of)) + + if not facts: + return ExtractionResult( + status="unsupported", + error="statement recognized but neither NAV nor flows could be read", + ) + + return ExtractionResult( + status="ok", + facts=tuple(facts), + payload={ + "account_ref": account_ref, + "base_currency": currency, + "period_end": as_of.isoformat(), + "has_nav": nav is not None, + }, + ) + + @staticmethod + def _period_end(text: str) -> date | None: + """A NAV without an as-of date cannot be placed in a series, so this + either finds the real period end or gives up and says so.""" + match = _PERIOD_RE.search(text) + if match: + parsed = parse_date(match.group(2)) # the end of the range + if parsed: + return parsed + match = _STATEMENT_DATE_RE.search(text) + if match: + parsed = parse_date(match.group(1)) + if parsed: + return parsed + return None + + @staticmethod + def _net_asset_value(all_tables: list[Table]) -> Decimal | None: + for table in all_tables: + header_index = None + total_column = None + for idx, row in enumerate(table[:3]): + for col, cell in enumerate(row): + if normalize_header(cell) in {"current total", "total", "ending value"}: + header_index, total_column = idx, col + break + if header_index is not None: + break + if header_index is None or total_column is None: + continue + for row in table[header_index + 1 :]: + if not row: + continue + if row[0].strip().lower() in {"total", "net asset value", "total nav"}: + if total_column < len(row): + value = parse_decimal(row[total_column]) + if value is not None: + return value + return None + + @staticmethod + def _flows( + all_tables: list[Table], currency: str, fallback_date: date + ) -> list[ExtractedFact]: + # Quantity is what makes a table a trades table. Excluding it is how we + # avoid reading fills as deposits. + found = _find_table(all_tables, "when", "proceeds", exclude=("quantity",)) + if found is None: + return [] + rows, columns = found + + flows: list[ExtractedFact] = [] + for row in rows: + if _is_noise(row, columns): + continue + amount = parse_decimal(_cell(row, columns, "proceeds")) + if amount is None or amount == 0: + continue + row_currency = (_cell(row, columns, "currency") or currency).upper() + flows.append( + ExtractedFact( + as_of=parse_date(_cell(row, columns, "when")) or fallback_date, + kind=FactKind.CASH_FLOW, + currency=row_currency, + amount_minor=to_minor(amount, row_currency), + note="deposit" if amount > 0 else "withdrawal", + ) + ) + return flows + + +TRADE_CONFIRMATION = registry.register(IbkrTradeConfirmation()) +ACTIVITY_STATEMENT = registry.register(IbkrActivityStatement()) diff --git a/core/extractors/mime.py b/core/extractors/mime.py new file mode 100644 index 0000000..51b2698 --- /dev/null +++ b/core/extractors/mime.py @@ -0,0 +1,123 @@ +"""Pulling readable text out of a message, for extraction only. + +Nothing here goes anywhere near verification. By the time an extractor runs, the +signature has already been checked against the raw bytes; this module builds a +convenient *copy* to read, and that copy is never written back or re-hashed. +Keeping the two apart is the whole reason a mail library is safe to use at all. +""" + +from __future__ import annotations + +import email +import re +from dataclasses import dataclass +from email import policy +from html.parser import HTMLParser + + +@dataclass(frozen=True, slots=True) +class BodyPart: + content_type: str + text: str + + +def body_parts(raw: bytes) -> tuple[BodyPart, ...]: + message = email.message_from_bytes(raw, policy=policy.default) + parts: list[BodyPart] = [] + for part in message.walk(): + ctype = part.get_content_type() + if ctype not in ("text/plain", "text/html"): + continue + try: + payload = part.get_content() + except Exception: # malformed encoding shouldn't kill the whole read + raw_payload = part.get_payload(decode=True) + if raw_payload is None: + continue + payload = raw_payload.decode("utf-8", errors="replace") + parts.append(BodyPart(ctype, payload)) + return tuple(parts) + + +def first_of(parts: tuple[BodyPart, ...], content_type: str) -> str | None: + for part in parts: + if part.content_type == content_type: + return part.text + return None + + +def header(raw: bytes, name: str) -> str | None: + message = email.message_from_bytes(raw, policy=policy.default) + value = message.get(name) + return str(value) if value is not None else None + + +# -- tables --------------------------------------------------------------- + +Row = list[str] +Table = list[Row] + + +class _TableCollector(HTMLParser): + """Flattens every into rows of cell text. + + Nested markup inside a cell is collapsed to its text, which is what we want: + `` and `` should extract identically. + """ + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.tables: list[Table] = [] + self._stack: list[Table] = [] + self._row: Row | None = None + self._cell: list[str] | None = None + + def handle_starttag(self, tag: str, attrs) -> None: + if tag == "table": + self._stack.append([]) + elif tag == "tr" and self._stack: + self._row = [] + elif tag in ("td", "th") and self._row is not None: + self._cell = [] + elif tag == "br" and self._cell is not None: + self._cell.append(" ") + + def handle_endtag(self, tag: str) -> None: + if tag in ("td", "th") and self._cell is not None and self._row is not None: + self._row.append(_squash(" ".join(self._cell))) + self._cell = None + elif tag == "tr" and self._row is not None and self._stack: + if self._cell is not None: # unclosed cell + self._row.append(_squash(" ".join(self._cell))) + self._cell = None + self._stack[-1].append(self._row) + self._row = None + elif tag == "table" and self._stack: + self.tables.append(self._stack.pop()) + + def handle_data(self, data: str) -> None: + if self._cell is not None: + self._cell.append(data) + + def close(self) -> None: # flush anything left open by sloppy markup + super().close() + while self._stack: + self.tables.append(self._stack.pop()) + + +def _squash(text: str) -> str: + return re.sub(r"\s+", " ", text).replace("\xa0", " ").strip() + + +def tables(html: str) -> list[Table]: + parser = _TableCollector() + parser.feed(html) + parser.close() + return [t for t in parser.tables if t] + + +def text_from_html(html: str) -> str: + """Crude tag strip, used only for regex sweeps over prose.""" + without_tags = re.sub(r"<(script|style)[^>]*>.*?", " ", html, flags=re.S | re.I) + without_tags = re.sub(r"<[^>]+>", " ", without_tags) + return _squash(without_tags) diff --git a/core/extractors/numbers.py b/core/extractors/numbers.py new file mode 100644 index 0000000..8508d8f --- /dev/null +++ b/core/extractors/numbers.py @@ -0,0 +1,110 @@ +"""Turning printed numbers and dates back into exact values. + +Everything here is strict about one thing: it never produces a float. A number +that cannot be read exactly comes back as None and the caller decides what that +means, which is better than a plausible wrong number entering a hashed fact. +""" + +from __future__ import annotations + +import re +from datetime import date, datetime +from decimal import Decimal, InvalidOperation, ROUND_HALF_EVEN + +from core.money import exponent_for + +_CURRENCY_JUNK = re.compile(r"[$£€¥,\s ]") +_TRAILING_CCY = re.compile(r"\s*[A-Z]{3}\s*$") + + +def parse_decimal(text: str | None) -> Decimal | None: + """Read a printed number exactly, or not at all. + + Handles thousands separators, currency symbols, unicode minus, and the + accounting convention where a negative is wrapped in parentheses. + """ + if text is None: + return None + cleaned = text.strip() + if not cleaned or cleaned in {"-", "--", "—", "n/a", "N/A"}: + return None + + cleaned = _TRAILING_CCY.sub("", cleaned) + + negative = False + if cleaned.startswith("(") and cleaned.endswith(")"): + negative = True + cleaned = cleaned[1:-1] + + cleaned = cleaned.replace("−", "-") # unicode minus + cleaned = _CURRENCY_JUNK.sub("", cleaned) + if not cleaned or cleaned in {"-", "+"}: + return None + + try: + value = Decimal(cleaned) + except InvalidOperation: + return None + if not value.is_finite(): + return None + return -value if negative else value + + +def to_minor(amount: Decimal, currency: str) -> int: + """Money to integer minor units, quantized deliberately. + + Rounding happens here, once, in the open — rather than accumulating silently + somewhere downstream where nobody can point at it. + """ + exponent = exponent_for(currency) + scaled = (amount * (10**exponent)).quantize(Decimal(1), rounding=ROUND_HALF_EVEN) + return int(scaled) + + +_DATE_PATTERNS = ( + "%Y-%m-%d", + "%Y/%m/%d", + "%d/%m/%Y", + "%m/%d/%Y", + "%d-%b-%y", + "%d-%b-%Y", + "%B %d, %Y", + "%b %d, %Y", + "%d %B %Y", +) + + +def parse_date(text: str | None) -> date | None: + """Read the date part of a printed timestamp. + + Ambiguous formats are a real hazard here — 03/04/2026 is two different days + depending on which side of the Atlantic printed it — so ISO is tried first + and the fallbacks are ordered by what the institution in question actually + emits. + """ + if not text: + return None + cleaned = text.strip() + + # Try the whole string before trimming anything. "October 31, 2025" carries + # a comma that is part of the date, not a separator before a time, and + # splitting on it first turns a valid date into "October 31". + for candidate in (cleaned, _strip_time(cleaned)): + for pattern in _DATE_PATTERNS: + try: + return datetime.strptime(candidate, pattern).date() + except ValueError: + continue + return None + + +_TIME_SUFFIX = re.compile(r"[,;]?\s*\d{1,2}:\d{2}(:\d{2})?(\s*[AP]\.?M\.?)?\s*$", re.I) + + +def _strip_time(text: str) -> str: + """Drop a trailing clock time: `2026-07-14, 09:31:04` is a date to us.""" + return _TIME_SUFFIX.sub("", text).strip().rstrip(",").strip() + + +def normalize_header(text: str) -> str: + return re.sub(r"\s+", " ", text).strip().lower().rstrip(":") diff --git a/core/extractors/selfreported.py b/core/extractors/selfreported.py new file mode 100644 index 0000000..e55f316 --- /dev/null +++ b/core/extractors/selfreported.py @@ -0,0 +1,107 @@ +"""CSV that somebody typed. + +This exists so that imported data can be graded honestly rather than refused. +A manager with eleven signed months and one month that only exists in a +spreadsheet should be able to show all twelve — with the twelfth marked for +what it is, and with every metric spanning it capped at `self_reported`. + +That will feel punitive, and it is exactly right. The alternative is a number +that looks as strong as its strongest input. +""" + +from __future__ import annotations + +import csv +import io +from dataclasses import dataclass + +from core.extraction.base import ExtractedFact, ExtractionResult, registry +from core.extractors.numbers import normalize_header, parse_date, parse_decimal, to_minor +from core.types import DocType, FactKind + +INSTITUTION_ID = "self-reported" + +_NAV_ALIASES = {"nav", "net asset value", "value", "amount", "total"} +_DATE_ALIASES = {"as_of", "as of", "date", "period_end", "period end"} +_CCY_ALIASES = {"currency", "ccy"} + + +@dataclass(frozen=True, slots=True) +class SelfReportedNavCsv: + id: str = "self.nav_csv" + version: str = "1.0.0" + institution_id: str = INSTITUTION_ID + doc_type: DocType = DocType.STATEMENT + + def recognizes(self, raw: bytes) -> bool: + head = raw[:2048].decode("utf-8", errors="replace").lower() + first_line = head.splitlines()[0] if head.splitlines() else "" + if "," not in first_line: + return False + headers = {normalize_header(h) for h in first_line.split(",")} + return bool(headers & _DATE_ALIASES) and bool(headers & _NAV_ALIASES) + + def extract(self, raw: bytes) -> ExtractionResult: + text = raw.decode("utf-8", errors="replace") + reader = csv.DictReader(io.StringIO(text)) + if not reader.fieldnames: + return ExtractionResult(status="unsupported", error="no CSV header row") + + columns = {normalize_header(name): name for name in reader.fieldnames} + date_column = _pick(columns, _DATE_ALIASES) + nav_column = _pick(columns, _NAV_ALIASES) + ccy_column = _pick(columns, _CCY_ALIASES) + + if date_column is None or nav_column is None: + return ExtractionResult( + status="unsupported", + error="CSV needs a date column and a NAV column", + ) + + facts: list[ExtractedFact] = [] + skipped: list[str] = [] + for row in reader: + as_of = parse_date(row.get(date_column)) + amount = parse_decimal(row.get(nav_column)) + if as_of is None or amount is None: + skipped.append(",".join(f"{k}={v}" for k, v in row.items())) + continue + currency = (row.get(ccy_column) if ccy_column else None) or "USD" + currency = currency.strip().upper() + facts.append( + ExtractedFact( + as_of=as_of, + kind=FactKind.NAV, + currency=currency, + amount_minor=to_minor(amount, currency), + note="self-reported: no institution vouches for this figure", + ) + ) + + if not facts: + return ExtractionResult( + status="unsupported", + error="no CSV row parsed cleanly", + payload={"skipped_rows": skipped}, + ) + + return ExtractionResult( + status="ok", + facts=tuple(facts), + payload={ + "rows": len(facts), + "skipped_rows": skipped, + "base_currency": facts[0].currency, + "account_ref": None, + }, + ) + + +def _pick(columns: dict[str, str], aliases: set[str]) -> str | None: + for normalized, original in columns.items(): + if normalized in aliases: + return original + return None + + +NAV_CSV = registry.register(SelfReportedNavCsv()) diff --git a/core/merkle.py b/core/merkle.py new file mode 100644 index 0000000..6b03a15 --- /dev/null +++ b/core/merkle.py @@ -0,0 +1,129 @@ +"""A Merkle tree over sorted leaves, with proofs a browser can re-fold. + +Two details that matter more than they look: + +Leaves and internal nodes are hashed under different prefixes. Without that, an +attacker can present an internal node as if it were a leaf and prove membership +of something that was never committed. + +An odd node is *promoted* to the next level rather than hashed against a copy of +itself. Duplicating the last leaf is the classic construction and the classic +bug: it lets two different leaf sets fold to the same root. +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from typing import Iterable, Literal + +_NODE_PREFIX = b"\x01" + +Side = Literal["left", "right"] + + +def _node(left: bytes, right: bytes) -> bytes: + return hashlib.sha256(_NODE_PREFIX + left + right).digest() + + +@dataclass(frozen=True, slots=True) +class ProofStep: + sibling: bytes + side: Side # which side the *sibling* sits on + + def as_dict(self) -> dict[str, str]: + return {"sibling": self.sibling.hex(), "side": self.side} + + +@dataclass(frozen=True, slots=True) +class MerkleProof: + leaf: bytes + steps: tuple[ProofStep, ...] + + def as_dict(self) -> dict[str, object]: + return { + "leaf": self.leaf.hex(), + "steps": [s.as_dict() for s in self.steps], + } + + +class EmptyTree(ValueError): + """Nothing was committed. An empty root would be a commitment to nothing + that still looks like a commitment, so we refuse to build one.""" + + +class MerkleTree: + """Leaves are sorted before folding, so the root depends on the *set* of + evidence and not on the order our pipeline happened to emit it.""" + + __slots__ = ("_leaves", "_levels") + + def __init__(self, leaves: Iterable[bytes]) -> None: + materialized = sorted(bytes(leaf) for leaf in leaves) + if not materialized: + raise EmptyTree("refusing to build a Merkle tree over zero leaves") + for leaf in materialized: + if len(leaf) != 32: + raise ValueError(f"leaf must be a 32-byte digest, got {len(leaf)} bytes") + self._leaves: tuple[bytes, ...] = tuple(materialized) + self._levels: tuple[tuple[bytes, ...], ...] = self._fold(self._leaves) + + @staticmethod + def _fold(leaves: tuple[bytes, ...]) -> tuple[tuple[bytes, ...], ...]: + levels = [leaves] + current = leaves + while len(current) > 1: + nxt: list[bytes] = [] + for i in range(0, len(current) - 1, 2): + nxt.append(_node(current[i], current[i + 1])) + if len(current) % 2: + nxt.append(current[-1]) # promoted, not duplicated + current = tuple(nxt) + levels.append(current) + return tuple(levels) + + @property + def leaves(self) -> tuple[bytes, ...]: + return self._leaves + + @property + def root(self) -> bytes: + return self._levels[-1][0] + + def __len__(self) -> int: + return len(self._leaves) + + def __contains__(self, leaf: bytes) -> bool: + return bytes(leaf) in self._leaves + + def proof_for(self, leaf: bytes) -> MerkleProof: + leaf = bytes(leaf) + try: + index = self._leaves.index(leaf) + except ValueError: + raise KeyError("leaf is not in this tree") from None + + steps: list[ProofStep] = [] + for level in self._levels[:-1]: + if index % 2 == 0: + if index + 1 < len(level): + steps.append(ProofStep(level[index + 1], "right")) + # else: promoted, no sibling at this level + else: + steps.append(ProofStep(level[index - 1], "left")) + index //= 2 + return MerkleProof(leaf, tuple(steps)) + + +def verify_proof(proof: MerkleProof, root: bytes) -> bool: + """Re-fold a leaf to a root. The browser runs the same walk in TypeScript; + if the two ever disagree, one of them is wrong and the UI says so.""" + current = proof.leaf + for step in proof.steps: + if step.side == "right": + current = _node(current, step.sibling) + elif step.side == "left": + current = _node(step.sibling, current) + else: # pragma: no cover - guarded by the Side literal + raise ValueError(f"bad proof step side: {step.side!r}") + return current == bytes(root) diff --git a/core/metrics/__init__.py b/core/metrics/__init__.py new file mode 100644 index 0000000..0137616 --- /dev/null +++ b/core/metrics/__init__.py @@ -0,0 +1,45 @@ +from core.metrics.engine import ( + CTX, + Config, + FeeModel, + QUANT, + TWO_AND_TWENTY, + Uncomputable, + compute, + historical_var, + max_drawdown, + modeled_fee_minor, + period_return, + periodic_returns, + return_index, +) +from core.metrics.series import ( + Flow, + InsufficientSeries, + Period, + Series, + build_series, + periods_per_year, +) + +__all__ = [ + "CTX", + "Config", + "FeeModel", + "Flow", + "InsufficientSeries", + "Period", + "QUANT", + "Series", + "TWO_AND_TWENTY", + "Uncomputable", + "build_series", + "compute", + "historical_var", + "max_drawdown", + "modeled_fee_minor", + "period_return", + "periodic_returns", + "periods_per_year", + "return_index", +] diff --git a/core/metrics/engine.py b/core/metrics/engine.py new file mode 100644 index 0000000..34cdb6a --- /dev/null +++ b/core/metrics/engine.py @@ -0,0 +1,486 @@ +"""The metrics engine. Pure, Decimal, and quantized on the way out. + +Determinism is the whole job. Python's `decimal` is correctly rounded and +platform-independent, so with an explicit context and an explicit quantization +step the same facts produce the same digits on every machine — which is what +lets a Merkle root over these numbers mean anything. + +Two judgement calls worth arguing about: + +Drawdown is measured on the return index, not on NAV. A manager who returns +capital to investors has not lost money, and a drawdown series built from raw +NAV says he has. Building the index from linked periodic returns removes flows +from the picture, which is the only version an allocator should accept. + +Gross is computed by adding observed fees back, and the fee model travels with +the number as an explicit input. Presenting a gross figure without saying what +was added back is how performance advertising goes wrong, and the SEC Marketing +Rule requires both figures side by side anyway. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Context, Decimal, ROUND_HALF_EVEN +from typing import Any, Sequence + +from core.metrics.series import Period, Series, periods_per_year +from core.tiers import Tier +from core.types import MetricResult + +# 34 significant digits: far more than the inputs justify, so that rounding +# happens once, at the quantization step, instead of accumulating quietly. +CTX = Context(prec=34, rounding=ROUND_HALF_EVEN) + +# Every metric value is pinned to this scale before it leaves the engine. +QUANT = Decimal("1E-10") + +ONE = Decimal(1) +ZERO = Decimal(0) +DAYS_PER_YEAR = Decimal("365.25") + + +class Uncomputable(ValueError): + """The series cannot support this calculation. + + Raised rather than returning a plausible-looking number. The pipeline turns + it into a finding, because "we could not compute this and here is why" is a + far better thing to show an allocator than a quietly wrong ratio. + """ + + +@dataclass(frozen=True, slots=True) +class FeeModel: + """What gets added back to reach a gross figure. + + Observed fees are the commissions we extracted from signed documents. On a + fund of any size they are rounding error — ninety dollars of commission + against fifteen million of NAV does not move a return — so a gross figure + that differs from net in any interesting way is almost always about + management and performance fees, which no broker document reports. + + Those are therefore *modelled*, from terms the manager states, and the terms + travel with every gross number as an explicit input. A gross figure without + its fee terms attached is exactly the kind of performance advertising the + Marketing Rule exists to stop. + """ + + basis: str = "observed" + description: str = "Commissions and fees as extracted from source documents." + management_bps_per_annum: Decimal = ZERO + performance_bps_of_gain: Decimal = ZERO + + @property + def models_anything(self) -> bool: + return self.management_bps_per_annum > 0 or self.performance_bps_of_gain > 0 + + def canonical_form(self) -> dict[str, Any]: + return { + "basis": self.basis, + "description": self.description, + "management_bps_per_annum": self.management_bps_per_annum, + "performance_bps_of_gain": self.performance_bps_of_gain, + } + + +TWO_AND_TWENTY = FeeModel( + basis="observed_plus_stated_terms", + description=( + "Observed commissions, plus management and performance fees accrued from " + "the terms the manager states. Not read from any signed document." + ), + management_bps_per_annum=Decimal(200), + performance_bps_of_gain=Decimal(2000), +) + + +def modeled_fee_minor(period: Period, model: FeeModel) -> int: + """Fees the manager charged that no broker statement itemizes. + + NAV is reported after fees, so reaching gross means adding them back: + management accrues on the capital base pro rata, and performance is charged + on the gain that remains once management has been taken. + """ + if not model.models_anything: + return 0 + + base = Decimal(period.begin_minor) + year_fraction = CTX.divide(Decimal(period.days), DAYS_PER_YEAR) + management = CTX.multiply( + CTX.multiply(base, CTX.divide(model.management_bps_per_annum, Decimal(10_000))), + year_fraction, + ) + + gain_after_management = ( + Decimal(period.end_minor) + + Decimal(period.fees_minor) + + management + - base + - Decimal(period.net_flow_minor) + ) + performance = ZERO + if gain_after_management > 0: + performance = CTX.multiply( + gain_after_management, + CTX.divide(model.performance_bps_of_gain, Decimal(10_000)), + ) + + return int((management + performance).to_integral_value(rounding=ROUND_HALF_EVEN)) + + +@dataclass(frozen=True, slots=True) +class Config: + risk_free_annual: Decimal = Decimal("0.04") + var_confidence: Decimal = Decimal("0.95") + fee_model: FeeModel = field(default_factory=FeeModel) + + def canonical_form(self) -> dict[str, Any]: + return { + "risk_free_annual": self.risk_free_annual, + "var_confidence": self.var_confidence, + "fee_model": self.fee_model.canonical_form(), + } + + +# -- primitives ----------------------------------------------------------- + + +def _q(value: Decimal) -> Decimal: + quantized = CTX.quantize(value, QUANT) + return abs(quantized) if quantized == 0 else quantized + + +def _pow(base: Decimal, exponent: Decimal) -> Decimal: + """base ** exponent for real exponents, via ln/exp in a fixed context.""" + if base <= 0: + raise Uncomputable("cannot raise a non-positive growth factor to a real power") + return CTX.exp(CTX.multiply(exponent, CTX.ln(base))) + + +def _mean(values: Sequence[Decimal]) -> Decimal: + return CTX.divide(sum(values, ZERO), Decimal(len(values))) + + +def _sample_stdev(values: Sequence[Decimal]) -> Decimal: + if len(values) < 2: + raise Uncomputable("standard deviation needs at least two observations") + mean = _mean(values) + total = sum((CTX.power(v - mean, 2) for v in values), ZERO) + return CTX.sqrt(CTX.divide(total, Decimal(len(values) - 1))) + + +def period_return(period: Period, *, fee_model: FeeModel | None = None) -> Decimal: + """Modified Dietz for one period. + + R = (EMV - BMV - F) / (BMV + Σ wᵢFᵢ), where each flow is weighted by the + fraction of the period it was invested for. Day-weighting is what separates + this from a naive return: two million arriving on the last day of the month + did not have a month to work. + + `fee_model` of None gives the net return — the number as the investor + experienced it. Passing a model adds fees back and gives gross. + """ + begin = Decimal(period.begin_minor) + end = Decimal(period.end_minor) + if fee_model is not None: + end += Decimal(period.fees_minor) + Decimal(modeled_fee_minor(period, fee_model)) + + net_flow = Decimal(period.net_flow_minor) + days = Decimal(period.days) + + weighted = ZERO + for flow in period.flows: + elapsed = Decimal((flow.on - period.start).days) + if elapsed < 0: + elapsed = ZERO + if elapsed > days: + elapsed = days + weight = CTX.divide(days - elapsed, days) + weighted += CTX.multiply(weight, Decimal(flow.amount_minor)) + + denominator = begin + weighted + if denominator <= 0: + raise Uncomputable( + f"period {period.start}..{period.end} has an average capital base of " + f"{denominator} minor units; a return over it would be meaningless" + ) + return CTX.divide(end - begin - net_flow, denominator) + + +def periodic_returns(series: Series, *, fee_model: FeeModel | None = None) -> list[Decimal]: + return [period_return(p, fee_model=fee_model) for p in series.periods] + + +def return_index(returns: Sequence[Decimal]) -> list[Decimal]: + """Cumulative growth of one unit, flows removed. Drawdown lives on this.""" + index = [ONE] + for r in returns: + index.append(CTX.multiply(index[-1], ONE + r)) + return index + + +def max_drawdown(returns: Sequence[Decimal]) -> Decimal: + """Deepest peak-to-trough fall of the return index, as a negative ratio.""" + peak = ONE + worst = ZERO + for value in return_index(returns): + if value > peak: + peak = value + if peak > 0: + drop = CTX.divide(value - peak, peak) + if drop < worst: + worst = drop + return worst + + +def historical_var(returns: Sequence[Decimal], confidence: Decimal) -> Decimal: + """Nearest-rank historical VaR, returned as a positive loss ratio. + + No distribution is assumed. With twelve monthly observations a 95% VaR is + simply the worst month, and saying so plainly beats fitting a normal to a + dozen points and implying more precision than exists. + """ + if not returns: + raise Uncomputable("VaR needs at least one observation") + alpha = ONE - confidence + ordered = sorted(returns) + rank = int(CTX.divide(CTX.multiply(alpha, Decimal(len(ordered))), ONE).to_integral_value(rounding="ROUND_CEILING")) + index = max(rank - 1, 0) + return -ordered[index] + + +# -- the engine ----------------------------------------------------------- + + +def compute(series: Series, config: Config | None = None) -> tuple[MetricResult, ...]: + """Every metric, net and gross, each carrying the weakest tier that fed it.""" + config = config or Config() + if not series.periods: + raise Uncomputable("no valuation periods in this series") + + ppy = periods_per_year(series.periods) + total_days = Decimal(sum(p.days for p in series.periods)) + years = CTX.divide(total_days, DAYS_PER_YEAR) + tier = series.tier + + results: list[MetricResult] = [] + for basis in ("net", "gross"): + results.extend( + _compute_basis( + series, + config, + basis=basis, + ppy=ppy, + years=years, + tier=tier, + ) + ) + return tuple(results) + + +def _compute_basis( + series: Series, + config: Config, + *, + basis: str, + ppy: Decimal, + years: Decimal, + tier: Tier, +) -> list[MetricResult]: + fee_model = config.fee_model if basis == "gross" else None + returns = periodic_returns(series, fee_model=fee_model) + n = len(returns) + + shared: dict[str, Any] = { + "periods": n, + "period_start": series.start, + "period_end": series.end, + "periods_per_year": ppy, + "years": _q(years), + "currency": series.currency, + } + if fee_model is not None: + shared["fee_model"] = fee_model.canonical_form() + shared["observed_fees_minor"] = sum(p.fees_minor for p in series.periods) + shared["modeled_fees_minor"] = sum( + modeled_fee_minor(p, fee_model) for p in series.periods + ) + + def metric( + key: str, value: Decimal, unit: str, formula: str, **inputs: Any + ) -> MetricResult: + return MetricResult( + key=key, + value=_q(value), + unit=unit, + formula=formula, + tier=tier, + basis=basis, + inputs={**shared, **inputs}, + ) + + results: list[MetricResult] = [] + + growth = ONE + for r in returns: + growth = CTX.multiply(growth, ONE + r) + cumulative = growth - ONE + + results.append( + metric( + "twr_cumulative", + cumulative, + "ratio", + "Π(1 + Rᵢ) − 1, where Rᵢ is the Modified Dietz return of period i", + ) + ) + + # Whole-period Modified Dietz, kept alongside linked TWR because the two + # disagree whenever flows are large, and the gap is informative. + whole = _whole_period_dietz(series, fee_model=fee_model) + results.append( + metric( + "modified_dietz", + whole, + "ratio", + "(EMV − BMV − F) / (BMV + Σ wᵢFᵢ) across the whole series", + net_flow_minor=sum(p.net_flow_minor for p in series.periods), + ) + ) + + if growth > 0 and years > 0: + annualized = _pow(growth, CTX.divide(ONE, years)) - ONE + else: + annualized = Decimal(-1) + results.append( + metric( + "twr_annualized", + annualized, + "ratio", + "Π(1 + Rᵢ)^(1/years) − 1", + ) + ) + + drawdown = max_drawdown(returns) + results.append( + metric( + "max_drawdown", + drawdown, + "ratio", + "min over t of (Iₜ − peak(I)ₜ) / peak(I)ₜ, on the return index — " + "flows removed, so a withdrawal is not counted as a loss", + ) + ) + + if n >= 2: + stdev = _sample_stdev(returns) + volatility = CTX.multiply(stdev, CTX.sqrt(ppy)) + results.append( + metric( + "volatility_annualized", + volatility, + "ratio", + "sample stdev of periodic returns × √(periods per year)", + periodic_stdev=_q(stdev), + ) + ) + + rf = config.risk_free_annual + excess = annualized - rf + if volatility > 0: + results.append( + metric( + "sharpe", + CTX.divide(excess, volatility), + "ratio", + "(annualized TWR − risk-free) / annualized volatility", + risk_free_annual=rf, + ) + ) + + downside = _downside_deviation(returns, rf, ppy) + if downside > 0: + results.append( + metric( + "sortino", + CTX.divide(excess, downside), + "ratio", + "(annualized TWR − risk-free) / annualized downside deviation " + "below the risk-free rate", + risk_free_annual=rf, + downside_deviation=_q(downside), + ) + ) + + if drawdown < 0: + results.append( + metric( + "calmar", + CTX.divide(annualized, abs(drawdown)), + "ratio", + "annualized TWR / |max drawdown|", + ) + ) + + var = historical_var(returns, config.var_confidence) + results.append( + metric( + "var_historical", + var, + "ratio", + f"historical VaR at {config.var_confidence} confidence, nearest-rank, " + "expressed as a positive loss over one period", + confidence=config.var_confidence, + method="nearest-rank, no distribution assumed", + ) + ) + + return results + + +def _whole_period_dietz(series: Series, *, fee_model: FeeModel | None) -> Decimal: + first, last = series.periods[0], series.periods[-1] + begin = Decimal(first.begin_minor) + end = Decimal(last.end_minor) + if fee_model is not None: + end += Decimal( + sum(p.fees_minor + modeled_fee_minor(p, fee_model) for p in series.periods) + ) + + total_days = Decimal(sum(p.days for p in series.periods)) + net_flow = ZERO + weighted = ZERO + elapsed_before = ZERO + + for period in series.periods: + for flow in period.flows: + offset = elapsed_before + Decimal((flow.on - period.start).days) + weight = CTX.divide(total_days - offset, total_days) + weighted += CTX.multiply(weight, Decimal(flow.amount_minor)) + net_flow += Decimal(flow.amount_minor) + elapsed_before += Decimal(period.days) + + denominator = begin + weighted + if denominator <= 0: + raise Uncomputable( + "whole-series average capital base is not positive; a return over it " + "would be meaningless" + ) + return CTX.divide(end - begin - net_flow, denominator) + + +def _downside_deviation( + returns: Sequence[Decimal], risk_free_annual: Decimal, ppy: Decimal +) -> Decimal: + """Deviation of the periods that fell short of the risk-free rate. + + Upside volatility is not risk, which is the entire reason Sortino exists + next to Sharpe. + """ + if len(returns) < 2: + raise Uncomputable("downside deviation needs at least two observations") + periodic_mar = _pow(ONE + risk_free_annual, CTX.divide(ONE, ppy)) - ONE + shortfalls = [min(ZERO, r - periodic_mar) for r in returns] + total = sum((CTX.power(s, 2) for s in shortfalls), ZERO) + periodic = CTX.sqrt(CTX.divide(total, Decimal(len(returns) - 1))) + return CTX.multiply(periodic, CTX.sqrt(ppy)) diff --git a/core/metrics/series.py b/core/metrics/series.py new file mode 100644 index 0000000..e914f63 --- /dev/null +++ b/core/metrics/series.py @@ -0,0 +1,185 @@ +"""Turning a bag of facts into an ordered series of valuation periods. + +A period runs from one NAV observation to the next, and carries the flows that +landed inside it. That shape is what every metric downstream is computed from, +and building it is where most of the honesty lives: a withdrawal that gets +mistaken for a loss will quietly ruin a track record, and a NAV with no date +cannot be placed in the series at all. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +from typing import Iterable, Sequence + +from core.tiers import Tier, min_tier +from core.types import Fact, FactKind + + +@dataclass(frozen=True, slots=True) +class Flow: + """External money in or out. Positive is a contribution.""" + + on: date + amount_minor: int + tier: Tier + + +@dataclass(frozen=True, slots=True) +class Period: + start: date + end: date + begin_minor: int + end_minor: int + flows: tuple[Flow, ...] + fees_minor: int + tiers: tuple[Tier, ...] + + @property + def days(self) -> int: + return max((self.end - self.start).days, 1) + + @property + def net_flow_minor(self) -> int: + return sum(f.amount_minor for f in self.flows) + + @property + def tier(self) -> Tier: + return min_tier(self.tiers) + + +@dataclass(frozen=True, slots=True) +class Series: + currency: str + periods: tuple[Period, ...] + observations: tuple[tuple[date, int], ...] + + def __bool__(self) -> bool: + return bool(self.periods) + + @property + def tier(self) -> Tier: + return min_tier([t for p in self.periods for t in p.tiers]) + + @property + def start(self) -> date: + return self.periods[0].start + + @property + def end(self) -> date: + return self.periods[-1].end + + +class InsufficientSeries(ValueError): + """Fewer than two NAV observations. One point is a balance, not a record.""" + + +def build_series(facts: Iterable[Fact], *, currency: str | None = None) -> Series: + """Order NAV observations and slot flows and fees into the gaps between them. + + Facts in another currency are dropped rather than converted. Converting + would mean inventing an FX rate nobody signed, which is precisely the kind + of unsourced number this whole system exists to refuse. + """ + facts = list(facts) + navs = sorted( + (f for f in facts if f.kind is FactKind.NAV and f.amount_minor is not None), + key=lambda f: (f.as_of, f.amount_minor), + ) + if currency is None: + currency = navs[0].currency if navs else "USD" + navs = [f for f in navs if f.currency == currency] + + # One NAV per date; a later duplicate replaces an earlier one. + deduped: dict[date, Fact] = {} + for fact in navs: + deduped[fact.as_of] = fact + ordered = [deduped[key] for key in sorted(deduped)] + + if len(ordered) < 2: + raise InsufficientSeries( + f"need at least two NAV observations in {currency} to compute a return, " + f"found {len(ordered)}" + ) + + flows = sorted( + ( + f + for f in facts + if f.kind is FactKind.CASH_FLOW + and f.amount_minor is not None + and f.currency == currency + ), + key=lambda f: f.as_of, + ) + fees = [ + f + for f in facts + if f.kind is FactKind.FEE and f.amount_minor is not None and f.currency == currency + ] + + periods: list[Period] = [] + for previous, current in zip(ordered, ordered[1:]): + window_flows = tuple( + Flow(on=f.as_of, amount_minor=f.amount_minor or 0, tier=f.tier) + for f in flows + if previous.as_of < f.as_of <= current.as_of + ) + window_fees = [f for f in fees if previous.as_of < f.as_of <= current.as_of] + + tiers = ( + previous.tier, + current.tier, + *(f.tier for f in window_flows), + *(f.tier for f in window_fees), + ) + + periods.append( + Period( + start=previous.as_of, + end=current.as_of, + begin_minor=previous.amount_minor or 0, + end_minor=current.amount_minor or 0, + flows=window_flows, + # Fees arrive as negative amounts; the magnitude is what gets + # added back to reach a gross figure. + fees_minor=sum(abs(f.amount_minor or 0) for f in window_fees), + tiers=tiers, + ) + ) + + return Series( + currency=currency, + periods=tuple(periods), + observations=tuple((f.as_of, f.amount_minor or 0) for f in ordered), + ) + + +def periods_per_year(periods: Sequence[Period]) -> Decimal: + """Infer the cadence from the data instead of assuming monthly. + + Snapped to a standard cadence so that a February one day short of a + thirty-day month does not produce an annualization factor of 12.03 and a + Sharpe ratio nobody can reproduce. + """ + if not periods: + return Decimal(12) + total_days = sum(p.days for p in periods) + average = Decimal(total_days) / Decimal(len(periods)) + standard = { + Decimal(252): Decimal(1), # daily, trading days + Decimal(52): Decimal(7), + Decimal(26): Decimal(14), + Decimal(12): Decimal("30.4375"), + Decimal(4): Decimal("91.3125"), + Decimal(2): Decimal("182.625"), + Decimal(1): Decimal("365.25"), + } + best, best_gap = Decimal(12), None + for frequency, days in standard.items(): + gap = abs(average - days) + if best_gap is None or gap < best_gap: + best, best_gap = frequency, gap + return best diff --git a/core/money.py b/core/money.py new file mode 100644 index 0000000..5e9d188 --- /dev/null +++ b/core/money.py @@ -0,0 +1,126 @@ +"""Money is an integer count of minor units plus a currency code. Never a float. + +A float dollar amount is a different number on different machines once you have +divided by it a few times, and different numbers give different Merkle roots for +the same evidence. That would make the anchor meaningless, so the type system +refuses to represent money as a float at all. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal + +# Minor units per major unit. Anything not listed defaults to 2, which is right +# far more often than it is wrong, but the explicit table is what we trust. +_EXPONENTS: dict[str, int] = { + "USD": 2, + "EUR": 2, + "GBP": 2, + "CHF": 2, + "CAD": 2, + "AUD": 2, + "HKD": 2, + "SGD": 2, + "JPY": 0, + "KRW": 0, + "BTC": 8, + "ETH": 18, +} + +DEFAULT_EXPONENT = 2 + + +def exponent_for(currency: str) -> int: + return _EXPONENTS.get(currency.upper(), DEFAULT_EXPONENT) + + +class CurrencyMismatch(ValueError): + """Two amounts in different currencies met. There is no sane default here.""" + + +@dataclass(frozen=True, slots=True, order=False) +class Money: + minor: int + currency: str + + def __post_init__(self) -> None: + if not isinstance(self.minor, int) or isinstance(self.minor, bool): + raise TypeError(f"minor units must be int, got {type(self.minor).__name__}") + if not self.currency or len(self.currency) > 5: + raise ValueError(f"implausible currency code: {self.currency!r}") + object.__setattr__(self, "currency", self.currency.upper()) + + # -- construction ---------------------------------------------------- + + @classmethod + def from_decimal(cls, amount: Decimal, currency: str) -> Money: + """Exact only. A sub-minor-unit amount is a bug in the caller, not + something to round away quietly.""" + if not isinstance(amount, Decimal): + raise TypeError("build Money from Decimal or int, never float") + scaled = amount * (10 ** exponent_for(currency)) + if scaled != scaled.to_integral_value(): + raise ValueError( + f"{amount} {currency} is finer than one minor unit; " + "quantize deliberately before constructing Money" + ) + return cls(int(scaled), currency) + + @classmethod + def zero(cls, currency: str) -> Money: + return cls(0, currency) + + # -- conversion ------------------------------------------------------ + + def as_decimal(self) -> Decimal: + return Decimal(self.minor).scaleb(-exponent_for(self.currency)) + + # -- arithmetic ------------------------------------------------------ + + def _check(self, other: Money) -> None: + if self.currency != other.currency: + raise CurrencyMismatch(f"{self.currency} vs {other.currency}") + + def __add__(self, other: Money) -> Money: + self._check(other) + return Money(self.minor + other.minor, self.currency) + + def __sub__(self, other: Money) -> Money: + self._check(other) + return Money(self.minor - other.minor, self.currency) + + def __neg__(self) -> Money: + return Money(-self.minor, self.currency) + + def __abs__(self) -> Money: + return Money(abs(self.minor), self.currency) + + def __lt__(self, other: Money) -> bool: + self._check(other) + return self.minor < other.minor + + def __le__(self, other: Money) -> bool: + self._check(other) + return self.minor <= other.minor + + def __gt__(self, other: Money) -> bool: + self._check(other) + return self.minor > other.minor + + def __ge__(self, other: Money) -> bool: + self._check(other) + return self.minor >= other.minor + + def __bool__(self) -> bool: + return self.minor != 0 + + def __str__(self) -> str: + return f"{self.as_decimal()} {self.currency}" + + +def sum_money(amounts: list[Money], currency: str) -> Money: + total = Money.zero(currency) + for a in amounts: + total = total + a + return total diff --git a/core/reconcile.py b/core/reconcile.py new file mode 100644 index 0000000..9be32bf --- /dev/null +++ b/core/reconcile.py @@ -0,0 +1,158 @@ +"""Where two sources cover the same period, they must agree. + +A mismatch writes a finding; it never throws. Allocators care more about the +discrepancies we surface than the ones we hide, and a pipeline that halts on +disagreement just teaches everyone to stop sending the second source. +""" + +from __future__ import annotations + +from collections import defaultdict +from datetime import date, timedelta +from typing import Iterable + +from core.tiers import Tier +from core.types import Fact, FactKind, Finding, Severity + + +def reconcile(facts: Iterable[Fact]) -> tuple[Finding, ...]: + facts = list(facts) + findings: list[Finding] = [] + findings.extend(_conflicting_navs(facts)) + findings.extend(_series_gaps(facts)) + findings.extend(_unmeasured_flows(facts)) + findings.extend(_weak_evidence(facts)) + return tuple(findings) + + +def _unmeasured_flows(facts: list[Fact]) -> list[Finding]: + """Money that moved after the last valuation. + + A flow on or before the opening NAV is already inside that balance, which is + correct. A flow *after* the closing NAV is not measured by anything: the + return series ends before it, so its effect is invisible. Silently dropping + it would make the record look complete when it isn't. + """ + nav_dates = [f.as_of for f in facts if f.kind is FactKind.NAV] + if not nav_dates: + return [] + last = max(nav_dates) + + stranded = [ + f + for f in facts + if f.kind is FactKind.CASH_FLOW and f.as_of > last and f.amount_minor + ] + if not stranded: + return [] + + total = sum(f.amount_minor or 0 for f in stranded) + return [ + Finding( + kind="unmeasured_flow", + severity=Severity.WARNING, + as_of=min(f.as_of for f in stranded), + detail=( + f"{len(stranded)} cash flow(s) totalling {total} minor units are dated " + f"after the last NAV observation on {last}. No valuation covers them, so " + "their effect on the return is not measured here." + ), + ) + ] + + +def _conflicting_navs(facts: list[Fact]) -> list[Finding]: + """Two documents, one date, two different NAVs. Somebody is wrong.""" + by_date: dict[tuple[date, str], list[Fact]] = defaultdict(list) + for fact in facts: + if fact.kind is FactKind.NAV and fact.amount_minor is not None: + by_date[(fact.as_of, fact.currency)].append(fact) + + findings: list[Finding] = [] + for (as_of, currency), group in sorted(by_date.items()): + amounts = {f.amount_minor for f in group} + if len(amounts) <= 1: + continue + documents = sorted({f.document_sha256[:12] for f in group}) + spread = max(amounts) - min(amounts) # type: ignore[type-var] + findings.append( + Finding( + kind="nav_conflict", + severity=Severity.CRITICAL, + as_of=as_of, + detail=( + f"{len(amounts)} different NAV values for {as_of} in {currency}, " + f"spread {spread} minor units, across documents {', '.join(documents)}. " + "The later document was used; both remain in evidence." + ), + ) + ) + return findings + + +def _series_gaps(facts: list[Fact]) -> list[Finding]: + """A missing month in an otherwise monthly series. + + Worth saying out loud. The gap may be innocent, but a track record with a + hole in it is exactly what a bad month looks like after somebody tidies up, + and the reader should decide which it is. + """ + dates = sorted({f.as_of for f in facts if f.kind is FactKind.NAV}) + if len(dates) < 3: + return [] + + gaps = [(later - earlier).days for earlier, later in zip(dates, dates[1:])] + typical = sorted(gaps)[len(gaps) // 2] + if typical <= 0: + return [] + + findings: list[Finding] = [] + for earlier, later in zip(dates, dates[1:]): + span = (later - earlier).days + # Two typical periods with nothing in between is a missing observation, + # not a long month. + if span > typical * 1.8: + missing = round(span / typical) - 1 + findings.append( + Finding( + kind="series_gap", + severity=Severity.WARNING, + as_of=earlier + timedelta(days=typical), + detail=( + f"{span} days between NAV observations on {earlier} and {later}, " + f"against a typical {typical}. Roughly {missing} observation(s) " + "are missing from the series." + ), + ) + ) + return findings + + +def _weak_evidence(facts: list[Fact]) -> list[Finding]: + """Name the facts that drag the whole record's tier down. + + A metric carries min(tier) over its inputs, so a single self-reported row + decides the grade of a twelve-month number. If that is going to happen, the + reader should be told which row did it. + """ + weakest = min((f.tier for f in facts), default=None) + if weakest is None or weakest is Tier.SOURCE_SIGNED: + return [] + + culprits = [f for f in facts if f.tier is weakest] + documents = sorted({f.document_sha256[:12] for f in culprits}) + dates = sorted({f.as_of for f in culprits}) + span = f"{dates[0]}" if len(dates) == 1 else f"{dates[0]}..{dates[-1]}" + + return [ + Finding( + kind="weak_evidence", + severity=Severity.WARNING, + as_of=dates[0], + detail=( + f"{len(culprits)} fact(s) at tier {weakest.slug} covering {span}, from " + f"document(s) {', '.join(documents)}. Every metric spanning them is " + f"capped at {weakest.slug}, however strong the rest of the evidence is." + ), + ) + ] diff --git a/core/snapshot.py b/core/snapshot.py new file mode 100644 index 0000000..c43b19a --- /dev/null +++ b/core/snapshot.py @@ -0,0 +1,201 @@ +"""Building the commitment. + +The tree commits to four things at once: the evidence, the proof that the +evidence was signed, our reading of it, and the version of the code that did the +reading. All four, or the commitment is a fig leaf — a root over facts alone +would let us silently change the parser and keep the same story. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable + +from core.canonical import canonical, leaf_hash, sha256_hex +from core.merkle import MerkleProof, MerkleTree +from core.tiers import Tier, min_tier +from core.types import DkimVerdict, Document, Fact, Finding, MetricResult + +LEAF_DOCUMENT = "document" +LEAF_DKIM = "dkim_verdict" +LEAF_FACT = "fact" +LEAF_METRICS = "metrics" + +LEAF_KINDS = (LEAF_DOCUMENT, LEAF_DKIM, LEAF_FACT, LEAF_METRICS) + + +@dataclass(frozen=True, slots=True) +class LeafRef: + """A leaf plus enough context to tell a viewer what it is. + + `ref` is a human-traceable handle (a document hash, a fact hash) so the UI + can say *which* piece of evidence a proof covers instead of showing 32 + anonymous bytes. + """ + + kind: str + ref: str + digest: bytes + label: str + + def as_dict(self) -> dict[str, str]: + return { + "kind": self.kind, + "ref": self.ref, + "digest": self.digest.hex(), + "label": self.label, + } + + +@dataclass(frozen=True, slots=True) +class Snapshot: + """An immutable commitment to one state of a track record. + + `seq` is strictly monotonic per record. A gap in the sequence is visible to + anyone reading the chain, which is what makes backfilling detectable. + """ + + record_id: str + seq: int + root: bytes + metrics_hash: str + methodology_version: str + extractor_versions: dict[str, str] + leaves: tuple[LeafRef, ...] + tier: Tier + findings: tuple[Finding, ...] = () + + @property + def root_hex(self) -> str: + return "0x" + self.root.hex() + + def leaf_for(self, digest_hex: str) -> LeafRef | None: + wanted = digest_hex.removeprefix("0x").lower() + for leaf in self.leaves: + if leaf.digest.hex() == wanted: + return leaf + return None + + def proof_for(self, digest_hex: str) -> MerkleProof: + tree = MerkleTree(leaf.digest for leaf in self.leaves) + wanted = bytes.fromhex(digest_hex.removeprefix("0x")) + return tree.proof_for(wanted) + + def as_dict(self) -> dict[str, Any]: + return { + "record_id": self.record_id, + "seq": self.seq, + "root": self.root_hex, + "metrics_hash": self.metrics_hash, + "methodology_version": self.methodology_version, + "extractor_versions": dict(sorted(self.extractor_versions.items())), + "tier": self.tier.slug, + "leaf_count": len(self.leaves), + } + + +def metrics_leaf_payload( + metrics: Iterable[MetricResult], + methodology_version: str, + extractor_versions: dict[str, str], +) -> dict[str, Any]: + """The metrics leaf commits to the numbers *and* to the code that produced + them. Bump an extractor version and the root moves, which is the point.""" + return { + "methodology_version": methodology_version, + "extractor_versions": dict(sorted(extractor_versions.items())), + "metrics": [m.canonical_form() for m in sorted(metrics, key=_metric_sort_key)], + } + + +def _metric_sort_key(metric: MetricResult) -> tuple[str, str]: + return (metric.key, metric.basis) + + +def build_snapshot( + *, + record_id: str, + seq: int, + documents: Iterable[Document], + verdicts: Iterable[tuple[str, DkimVerdict]], + facts: Iterable[Fact], + metrics: Iterable[MetricResult], + methodology_version: str, + extractor_versions: dict[str, str], + findings: Iterable[Finding] = (), +) -> Snapshot: + """Fold everything we know into one root. Pure: same inputs, same bytes.""" + documents = list(documents) + verdicts = list(verdicts) + facts = list(facts) + metrics = list(metrics) + findings = tuple(findings) + + leaves: list[LeafRef] = [] + + for doc in documents: + leaves.append( + LeafRef( + kind=LEAF_DOCUMENT, + ref=doc.sha256, + digest=leaf_hash(LEAF_DOCUMENT, doc.canonical_form()), + label=f"{doc.doc_type.value} received via {doc.channel.value}", + ) + ) + + for doc_sha, verdict in verdicts: + payload = dict(verdict.canonical_form()) + payload["document_sha256"] = doc_sha + state = "verified" if verdict.verified else "failed" + leaves.append( + LeafRef( + kind=LEAF_DKIM, + ref=doc_sha, + digest=leaf_hash(LEAF_DKIM, payload), + label=f"DKIM {state} for {verdict.d_domain or 'unknown domain'}", + ) + ) + + for fact in facts: + form = fact.canonical_form() + digest = leaf_hash(LEAF_FACT, form) + leaves.append( + LeafRef( + kind=LEAF_FACT, + ref=sha256_hex(canonical(form)), + digest=digest, + label=f"{fact.kind.value} {fact.instrument or ''} on {fact.as_of}".strip(), + ) + ) + + metrics_payload = metrics_leaf_payload( + metrics, methodology_version, extractor_versions + ) + metrics_hash = sha256_hex(canonical(metrics_payload)) + leaves.append( + LeafRef( + kind=LEAF_METRICS, + ref=metrics_hash, + digest=leaf_hash(LEAF_METRICS, metrics_payload), + label=f"{len(metrics)} metrics under methodology {methodology_version}", + ) + ) + + tree = MerkleTree(leaf.digest for leaf in leaves) + + # The snapshot's own tier is the weakest thing in it. If there are no facts + # at all there is nothing to grade, and min_tier would rightly refuse — so + # an evidence-free snapshot is self-reported by construction. + tier = min_tier([f.tier for f in facts]) if facts else Tier.SELF_REPORTED + + return Snapshot( + record_id=record_id, + seq=seq, + root=tree.root, + metrics_hash=metrics_hash, + methodology_version=methodology_version, + extractor_versions=dict(sorted(extractor_versions.items())), + leaves=tuple(sorted(leaves, key=lambda leaf: leaf.digest)), + tier=tier, + findings=findings, + ) diff --git a/core/tiers.py b/core/tiers.py new file mode 100644 index 0000000..5c527f4 --- /dev/null +++ b/core/tiers.py @@ -0,0 +1,73 @@ +"""The trust ladder. + +A tier is a property of *evidence*, never of a connector or a customer, and it +is computed, never asserted. A fact inherits the tier of the document it came +from; a metric carries min(tier) over every fact that fed it. One CSV row in a +twelve-month TWR drags the whole number to self_reported. That is correct and it +is the point. +""" + +from __future__ import annotations + +from enum import IntEnum +from typing import Iterable + + +class Tier(IntEnum): + """Ordered weakest to strongest, so min() is the propagation rule.""" + + SELF_REPORTED = 0 + PLATFORM_OBSERVED = 1 + AGGREGATOR_ATTESTED = 2 + SOURCE_SIGNED = 3 + + @property + def slug(self) -> str: + return self.name.lower() + + @property + def label(self) -> str: + return _LABELS[self] + + @property + def meaning(self) -> str: + return _MEANINGS[self] + + @classmethod + def from_slug(cls, slug: str) -> Tier: + try: + return cls[slug.upper()] + except KeyError: + raise ValueError(f"unknown tier: {slug!r}") from None + + +_LABELS: dict[Tier, str] = { + Tier.SOURCE_SIGNED: "Source-signed", + Tier.AGGREGATOR_ATTESTED: "Aggregator-attested", + Tier.PLATFORM_OBSERVED: "Platform-observed", + Tier.SELF_REPORTED: "Self-reported", +} + +_MEANINGS: dict[Tier, str] = { + Tier.SOURCE_SIGNED: "The institution's own key verifies these bytes.", + Tier.AGGREGATOR_ATTESTED: "A named third party pulled it.", + Tier.PLATFORM_OBSERVED: "We saw it. Our word only.", + Tier.SELF_REPORTED: "Nobody vouches.", +} + + +class NoEvidence(ValueError): + """A tier was requested over an empty set of inputs. + + Deliberately fatal. Returning SOURCE_SIGNED would invent trust; returning + SELF_REPORTED would hide the fact that a metric was computed from nothing. + Either way the caller has a bug and should hear about it. + """ + + +def min_tier(tiers: Iterable[Tier]) -> Tier: + """The weakest link. This is the only way a metric ever gets a tier.""" + materialized = list(tiers) + if not materialized: + raise NoEvidence("cannot compute a tier over zero inputs") + return min(materialized) diff --git a/core/types.py b/core/types.py new file mode 100644 index 0000000..c427a95 --- /dev/null +++ b/core/types.py @@ -0,0 +1,283 @@ +"""Domain types. Pure data plus the one rule each type is responsible for. + +Every type that ends up in the Merkle tree defines `canonical_form()`, and that +projection — not the Python object — is what gets hashed. Adding a field to a +dataclass therefore does not silently change historical roots; changing +`canonical_form()` does, which is exactly the decision we want to be loud. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date, datetime +from decimal import Decimal +from enum import Enum +from typing import Any + +from core.tiers import Tier + + +class Channel(str, Enum): + """How evidence reached us.""" + + EML_UPLOAD = "eml_upload" + MAIL_INTAKE = "mail_intake" + IBKR_FLEX = "ibkr_flex" + ADMIN_SFTP = "admin_sftp" + CSV_UPLOAD = "csv_upload" + MANUAL = "manual" + + +# The strongest tier a channel could ever justify, before we look at the +# evidence itself. The actual tier is min(ceiling, what the evidence proves). +_CEILINGS: dict[Channel, Tier] = { + Channel.EML_UPLOAD: Tier.SOURCE_SIGNED, + Channel.MAIL_INTAKE: Tier.SOURCE_SIGNED, + Channel.IBKR_FLEX: Tier.AGGREGATOR_ATTESTED, + Channel.ADMIN_SFTP: Tier.AGGREGATOR_ATTESTED, + Channel.CSV_UPLOAD: Tier.SELF_REPORTED, + Channel.MANUAL: Tier.SELF_REPORTED, +} + + +def channel_ceiling(channel: Channel) -> Tier: + return _CEILINGS[channel] + + +def tier_for(channel: Channel, dkim_verified: bool | None) -> Tier: + """The one place a tier is ever assigned. + + A mail channel earns `source_signed` only when the institution's signature + actually verified. A failed signature does not fall back to + `aggregator_attested` — nobody attested to anything — it falls all the way + to `self_reported`, because at that point the only reason to believe the + document is that somebody uploaded it. + """ + ceiling = channel_ceiling(channel) + if channel in (Channel.EML_UPLOAD, Channel.MAIL_INTAKE): + return ceiling if dkim_verified else Tier.SELF_REPORTED + return ceiling + + +class DocType(str, Enum): + TRADE_CONFIRMATION = "trade_confirmation" + STATEMENT = "statement" + NAV_NOTICE = "nav_notice" + UNKNOWN = "unknown" + + +class FactKind(str, Enum): + TRADE = "trade" + POSITION = "position" + NAV = "nav" + CASH_FLOW = "cash_flow" + FEE = "fee" + + +class Severity(str, Enum): + INFO = "info" + WARNING = "warning" + CRITICAL = "critical" + + +@dataclass(frozen=True, slots=True) +class Institution: + """Who we will believe a signature from. + + `domains` is the allow-list matched against DKIM `d=`. A signature from + anywhere else — Gmail's on a forward, say — proves that party handled the + mail and nothing an allocator cares about. + """ + + id: str + name: str + domains: tuple[str, ...] + doc_types: tuple[DocType, ...] = () + + def signs_for(self, d_domain: str) -> bool: + candidate = d_domain.lower().rstrip(".") + return any( + candidate == d or candidate.endswith("." + d) + for d in (dom.lower() for dom in self.domains) + ) + + +@dataclass(frozen=True, slots=True) +class Document: + """Raw evidence, write-once. `sha256` is over the bytes exactly as received; + the blob store is keyed by it, so identical content is a no-op on re-ingest.""" + + sha256: str + blob_key: str + byte_length: int + received_at: datetime + channel: Channel + institution_id: str | None = None + doc_type: DocType = DocType.UNKNOWN + + def canonical_form(self) -> dict[str, Any]: + return { + "sha256": self.sha256, + "byte_length": self.byte_length, + "channel": self.channel.value, + "institution_id": self.institution_id, + "doc_type": self.doc_type.value, + } + + +@dataclass(frozen=True, slots=True) +class DkimVerdict: + """The cryptographic half of the story, and the captured key that keeps it + checkable after the selector leaves DNS. + + `dns_txt_record` is the whole point of anchoring: a signature we can verify + today is unverifiable in a year once the selector is retired, unless someone + wrote the key down at the time and committed to it. + """ + + verified: bool + d_domain: str | None + selector: str | None + algo: str | None + l_tag_present: bool + dns_txt_record: str | None + dns_captured_at: datetime | None + body_hash_matched: bool = False + signed_headers: tuple[str, ...] = () + failure_reason: str | None = None + + @property + def dns_record_hash(self) -> str | None: + if self.dns_txt_record is None: + return None + from core.canonical import sha256_hex + + return sha256_hex(self.dns_txt_record.encode("utf-8")) + + def canonical_form(self) -> dict[str, Any]: + return { + "verified": self.verified, + "d_domain": self.d_domain, + "selector": self.selector, + "algo": self.algo, + "l_tag_present": self.l_tag_present, + "dns_record_hash": self.dns_record_hash, + "dns_captured_at": self.dns_captured_at, + "body_hash_matched": self.body_hash_matched, + "signed_headers": list(self.signed_headers), + "failure_reason": self.failure_reason, + } + + +@dataclass(frozen=True, slots=True) +class Extraction: + """Our reading of a document. Deliberately a separate object from the + verdict above: DKIM proves the bytes left the institution, and says nothing + at all about whether our parser understood them.""" + + document_sha256: str + extractor_id: str + extractor_version: str + status: str # "ok" | "unsupported" | "failed" + payload: dict[str, Any] = field(default_factory=dict) + error: str | None = None + + def canonical_form(self) -> dict[str, Any]: + return { + "document_sha256": self.document_sha256, + "extractor_id": self.extractor_id, + "extractor_version": self.extractor_version, + "status": self.status, + } + + +@dataclass(frozen=True, slots=True) +class Fact: + """One observation, pointing back at the document that justifies it. + + A fact never carries a tier of its own choosing — it inherits the tier of + its document, assigned once at the verify step. + + Note on `price` vs `amount_minor`. Amounts are money and follow the rule: + integer minor units, never a float. A per-unit price is not an amount — FX + and futures quote well past two decimals, and squeezing 182.4750 into cents + would destroy real precision inside a hashed object. So price is a Decimal, + which `canonical.py` pins to a fixed scale and is every bit as deterministic + as an integer. The rule the plan actually cares about — no floats anywhere — + holds either way. + """ + + account_id: str + document_sha256: str + as_of: date + kind: FactKind + tier: Tier + currency: str + instrument: str | None = None + quantity: Decimal | None = None + price: Decimal | None = None + amount_minor: int | None = None + extractor_version: str | None = None + note: str | None = None + + def canonical_form(self) -> dict[str, Any]: + return { + "account_id": self.account_id, + "document_sha256": self.document_sha256, + "as_of": self.as_of, + "kind": self.kind.value, + "tier": self.tier.slug, + "currency": self.currency, + "instrument": self.instrument, + "quantity": self.quantity, + "price": self.price, + "amount_minor": self.amount_minor, + "extractor_version": self.extractor_version, + } + + +@dataclass(frozen=True, slots=True) +class MetricResult: + """A number, the formula that produced it, what fed it, and how strong the + weakest input was. Never displayed without the tier.""" + + key: str + value: Decimal + unit: str + formula: str + tier: Tier + inputs: dict[str, Any] = field(default_factory=dict) + basis: str = "net" # "net" | "gross" + + def canonical_form(self) -> dict[str, Any]: + return { + "key": self.key, + "value": self.value, + "unit": self.unit, + "formula": self.formula, + "tier": self.tier.slug, + "basis": self.basis, + "inputs": self.inputs, + } + + +@dataclass(frozen=True, slots=True) +class Finding: + """A disagreement between two sources covering the same period. + + Written, never thrown. Allocators care more about the discrepancies we + surface than the ones we hide. + """ + + kind: str + severity: Severity + detail: str + as_of: date | None = None + + def canonical_form(self) -> dict[str, Any]: + return { + "kind": self.kind, + "severity": self.severity.value, + "detail": self.detail, + "as_of": self.as_of, + } diff --git a/demo/__init__.py b/demo/__init__.py new file mode 100644 index 0000000..f49d9cf --- /dev/null +++ b/demo/__init__.py @@ -0,0 +1,6 @@ +"""Demo and fixture generation. + +Everything in here is synthetic and labelled as such. It exists so the DKIM path +can be exercised against real cryptography without shipping somebody's actual +brokerage mail, and so the demo is staged rather than typed. +""" diff --git a/demo/corpus.py b/demo/corpus.py new file mode 100644 index 0000000..e7c3471 --- /dev/null +++ b/demo/corpus.py @@ -0,0 +1,520 @@ +"""A signed fixture corpus we control end to end. + +These messages are synthetic and the repo says so everywhere it shows them. We +cannot obtain a real Interactive Brokers signature to commit to a public repo, +and forging one against the real `interactivebrokers.com` domain would be both +dishonest and useless — anyone checking live DNS would catch it in a second. So +the corpus signs for `ibkr-demo.podarena.test`, a domain that plainly does not +exist, using a keypair generated at build time. + +What the corpus is genuinely good for is the part that matters: every message +below is signed with real RSA and verified by the real verifier, so the five +ways DKIM breaks are exercised against actual cryptography rather than mocked. + +The interesting fixture is `archived`. It is signed with a selector that is +absent from live DNS — the situation every signature ends up in eventually — +and it still verifies against the record we captured at first sight. That is the +argument for anchoring the capture, made concrete. +""" + +from __future__ import annotations + +import base64 +import json +from dataclasses import dataclass, field +from datetime import date, datetime, timedelta, timezone +from decimal import Decimal +from pathlib import Path + +import dkim as dkimpy +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from core.extractors.ibkr import INSTITUTION as IBKR +from core.types import Institution + +DEMO_DOMAIN = "ibkr-demo.podarena.test" +FORWARDER_DOMAIN = "mail-forwarder.podarena.test" + +CURRENT_SELECTOR = "blitz2026" +RETIRED_SELECTOR = "legacy2024" + +ACCOUNT_REF = "U4471902" +BASE_CURRENCY = "USD" + + +def demo_institutions() -> tuple[Institution, ...]: + """IBKR, plus the demo domain, for deployments running the fixture corpus. + + Kept out of `core.extractors` on purpose: the real allow-list should not + grow a test domain just because a demo needs one. + """ + return ( + Institution( + id=IBKR.id, + name=IBKR.name, + domains=IBKR.domains + (DEMO_DOMAIN,), + doc_types=IBKR.doc_types, + ), + ) + + +# -- keys ----------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class Keypair: + private_pem: bytes + dns_record: str + + +def make_keypair(bits: int = 2048) -> Keypair: + key = rsa.generate_private_key(public_exponent=65537, key_size=bits) + private_pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + public_der = key.public_key().public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + record = "v=DKIM1; k=rsa; p=" + base64.b64encode(public_der).decode("ascii") + return Keypair(private_pem=private_pem, dns_record=record) + + +# -- message bodies ------------------------------------------------------- + +_STYLE = ( + "font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif;" + "font-size:13px;color:#111" +) + + +def _trade_confirmation_html(trades: list[tuple[str, date, Decimal, Decimal, Decimal]]) -> str: + rows = [] + for symbol, when, qty, price, commission in trades: + proceeds = (-qty * price).quantize(Decimal("0.01")) + rows.append( + "" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + "" + ) + return f""" +

Trade Confirmation

+

Interactive Brokers · Account {ACCOUNT_REF} · Base Currency: {BASE_CURRENCY}

+
AAPLAAPL
{BASE_CURRENCY}{symbol}{when.isoformat()}, 14:31:07{qty:+,}{price:,.4f}{proceeds:,.2f}{commission:,.2f}
+ + +{"".join(rows)} +
CurrencySymbolDate/TimeQuantityT. PriceProceedsComm/Fee
+

This confirmation is furnished pursuant to SEC Rule 10b-10.

+""" + + +def _statement_html(period_end: date, nav: Decimal, flows: list[tuple[date, Decimal, str]]) -> str: + flow_rows = "".join( + "" + f"{BASE_CURRENCY}" + f"{when.isoformat()}" + f"{label}" + f"{amount:,.2f}" + "" + for when, amount, label in flows + ) + flows_table = ( + f"""

Deposits & Withdrawals

+ + +{flow_rows} +
CurrencySettle DateDescriptionAmount
""" + if flows + else "" + ) + return f""" +

Activity Statement

+

Interactive Brokers · Account {ACCOUNT_REF} · Base Currency: {BASE_CURRENCY}

+

Period : {period_end.replace(day=1).strftime("%B %-d, %Y")} - {period_end.strftime("%B %-d, %Y")}

+

Net Asset Value

+ + + + +
Asset ClassPrior TotalCurrent Total
Stocks-{nav:,.2f}
Total-{nav:,.2f}
+{flows_table} +""" + + +def _message(subject: str, html: str, sent: datetime, sender_domain: str) -> bytes: + """Raw RFC822 bytes with CRLF endings, built by hand. + + Not `email.message.EmailMessage`: the generator normalizes line endings and + re-wraps headers, and a signature taken over the result would be a signature + over something the recipient never saw. + """ + headers = [ + f"From: Interactive Brokers ", + "To: Ops ", + f"Subject: {subject}", + f"Date: {sent.strftime('%a, %d %b %Y %H:%M:%S %z')}", + f"Message-ID: <{sent.strftime('%Y%m%d%H%M%S')}.{abs(hash(subject)) % 10**8}@{sender_domain}>", + "MIME-Version: 1.0", + 'Content-Type: text/html; charset="utf-8"', + "Content-Transfer-Encoding: 8bit", + ] + return ("\r\n".join(headers) + "\r\n\r\n" + html.replace("\n", "\r\n")).encode("utf-8") + + +def _sign( + message: bytes, + keypair: Keypair, + *, + domain: str, + selector: str, + length: bool = False, +) -> bytes: + signature = dkimpy.sign( + message, + selector.encode("ascii"), + domain.encode("ascii"), + keypair.private_pem, + include_headers=[b"from", b"to", b"subject", b"date", b"message-id"], + canonicalize=(b"relaxed", b"simple"), + length=length, + ) + return signature + message + + +# -- the corpus ----------------------------------------------------------- + + +@dataclass +class Fixture: + name: str + filename: str + raw: bytes + expected_verified: bool + expectation: str + doc_kind: str = "statement" + + +@dataclass +class Corpus: + fixtures: list[Fixture] = field(default_factory=list) + captured_dns: dict[str, str] = field(default_factory=dict) + live_dns: dict[str, str] = field(default_factory=dict) + + def by_name(self, name: str) -> Fixture: + for fixture in self.fixtures: + if fixture.name == name: + return fixture + raise KeyError(name) + + def write(self, outdir: Path) -> Path: + outdir.mkdir(parents=True, exist_ok=True) + for fixture in self.fixtures: + (outdir / fixture.filename).write_bytes(fixture.raw) + (outdir / "captured_dns.json").write_text( + json.dumps(self.captured_dns, indent=2, sort_keys=True) + "\n" + ) + (outdir / "live_dns.json").write_text( + json.dumps(self.live_dns, indent=2, sort_keys=True) + "\n" + ) + (outdir / "manifest.json").write_text( + json.dumps( + [ + { + "name": f.name, + "file": f.filename, + "expected_verified": f.expected_verified, + "expectation": f.expectation, + "kind": f.doc_kind, + } + for f in self.fixtures + ], + indent=2, + ) + + "\n" + ) + return outdir + + +def _month_end(year: int, month: int) -> date: + if month == 12: + return date(year, 12, 31) + return date(year, month + 1, 1) - timedelta(days=1) + + +# A twelve-month NAV path with a real drawdown in it, so max drawdown and +# Sortino have something to bite on rather than a straight line up. +_NAV_PATH: list[tuple[int, int, str, list[tuple[int, str, str]]]] = [ + (2025, 8, "12480000.00", []), + (2025, 9, "12915400.00", []), + (2025, 10, "13402750.00", [(15, "2000000.00", "Deposit")]), + (2025, 11, "13108220.00", []), + (2025, 12, "13744910.00", []), + (2026, 1, "14320880.00", []), + (2026, 2, "13615330.00", []), # drawdown starts + (2026, 3, "12988470.00", []), + (2026, 4, "13571040.00", [(9, "-1500000.00", "Withdrawal")]), + (2026, 5, "14226690.00", []), + (2026, 6, "14905120.00", []), + (2026, 7, "15488330.00", []), +] + +_TRADES: list[tuple[str, date, Decimal, Decimal, Decimal]] = [ + ("AAPL", date(2026, 7, 14), Decimal("12000"), Decimal("214.8250"), Decimal("-38.40")), + ("MSFT", date(2026, 7, 14), Decimal("-4500"), Decimal("501.1100"), Decimal("-21.15")), + ("NVDA", date(2026, 7, 15), Decimal("8000"), Decimal("182.4750"), Decimal("-29.60")), +] + + +def build_corpus() -> Corpus: + """Generate keys, sign the messages, and record what DNS said at the time.""" + current = make_keypair() + retired = make_keypair() + forwarder = make_keypair() + + captured = { + f"{CURRENT_SELECTOR}._domainkey.{DEMO_DOMAIN}": current.dns_record, + f"{RETIRED_SELECTOR}._domainkey.{DEMO_DOMAIN}": retired.dns_record, + f"fwd._domainkey.{FORWARDER_DOMAIN}": forwarder.dns_record, + } + # Live DNS has moved on: the retired selector is simply gone. This is the + # normal end state of every DKIM key, and the reason a capture is worth + # committing to a chain. + live = { + f"{CURRENT_SELECTOR}._domainkey.{DEMO_DOMAIN}": current.dns_record, + f"fwd._domainkey.{FORWARDER_DOMAIN}": forwarder.dns_record, + } + + corpus = Corpus(captured_dns=captured, live_dns=live) + + # Twelve signed monthly statements: the actual track record. + for year, month, nav, flows in _NAV_PATH: + period_end = _month_end(year, month) + flow_rows = [ + (date(year, month, day), Decimal(amount), label) + for day, amount, label in flows + ] + sent = datetime(year, month, 28, 6, 5, 0, tzinfo=timezone.utc) + if period_end.day < 28: + sent = datetime.combine(period_end, sent.time()).replace(tzinfo=timezone.utc) + message = _message( + f"Activity Statement — {period_end.strftime('%B %Y')}", + _statement_html(period_end, Decimal(nav), flow_rows), + sent, + DEMO_DOMAIN, + ) + corpus.fixtures.append( + Fixture( + name=f"statement-{year}-{month:02d}", + filename=f"statement-{year}-{month:02d}.eml", + raw=_sign(message, current, domain=DEMO_DOMAIN, selector=CURRENT_SELECTOR), + expected_verified=True, + expectation="valid signature over NAV and flows", + ) + ) + + # A trade confirmation — the first extractor's real target. + confirmation = _message( + "Trade Confirmation — 15 July 2026", + _trade_confirmation_html(_TRADES), + datetime(2026, 7, 15, 21, 2, 0, tzinfo=timezone.utc), + DEMO_DOMAIN, + ) + signed_confirmation = _sign( + confirmation, current, domain=DEMO_DOMAIN, selector=CURRENT_SELECTOR + ) + corpus.fixtures.append( + Fixture( + name="trade-confirmation", + filename="trade-confirmation.eml", + raw=signed_confirmation, + expected_verified=True, + expectation="valid signature over three fills", + doc_kind="trade_confirmation", + ) + ) + + # 1. Archived: signed with a selector that has since left DNS. Verifies + # against the captured record and against nothing else. + archived = _message( + "Activity Statement — July 2024", + _statement_html(date(2024, 7, 31), Decimal("9120400.00"), []), + datetime(2024, 7, 31, 6, 5, 0, tzinfo=timezone.utc), + DEMO_DOMAIN, + ) + corpus.fixtures.append( + Fixture( + name="archived-retired-selector", + filename="archived-retired-selector.eml", + raw=_sign(archived, retired, domain=DEMO_DOMAIN, selector=RETIRED_SELECTOR), + expected_verified=True, + expectation="verifies only from the captured DNS record; the selector " + "is absent from live DNS", + ) + ) + + # 2. l= tag: only the first N body bytes are signed, so anything can be + # appended below. Hard fail, not a warning. + truncatable = _message( + "Activity Statement — June 2026 (partial coverage)", + _statement_html(date(2026, 6, 30), Decimal("14905120.00"), []), + datetime(2026, 6, 30, 6, 5, 0, tzinfo=timezone.utc), + DEMO_DOMAIN, + ) + signed_l = _sign( + truncatable, current, domain=DEMO_DOMAIN, selector=CURRENT_SELECTOR, length=True + ) + corpus.fixtures.append( + Fixture( + name="l-tag-appended", + filename="l-tag-appended.eml", + raw=signed_l + b"\r\n

Net Asset Value: 99,000,000.00

\r\n", + expected_verified=False, + expectation="l= tag present; content appended below the signed prefix", + ) + ) + + # 3. Tampered: one digit of the NAV changed after signing. Same length, so + # only the body hash catches it. + tampered = signed_confirmation.replace(b"214.8250", b"274.8250", 1) + assert tampered != signed_confirmation, "tamper fixture did not change anything" + corpus.fixtures.append( + Fixture( + name="tampered-body", + filename="tampered-body.eml", + raw=tampered, + expected_verified=False, + expectation="one price digit altered after signing; body hash must fail", + doc_kind="trade_confirmation", + ) + ) + + # 4. Forwarded by hand: the client rewrote the body, so the institution's + # signature is broken. The forwarder's own signature is intact and worth + # nothing, which is the point of the fixture. + forwarded_body = _statement_html(date(2026, 5, 31), Decimal("14226690.00"), []) + forwarded_message = _message( + "Fwd: Activity Statement — May 2026", + "

---------- Forwarded message ----------

" + forwarded_body, + datetime(2026, 6, 1, 9, 14, 0, tzinfo=timezone.utc), + FORWARDER_DOMAIN, + ) + broken_institution_sig = _sign( + _message( + "Fwd: Activity Statement — May 2026", + forwarded_body, # what was signed + datetime(2026, 6, 1, 9, 14, 0, tzinfo=timezone.utc), + FORWARDER_DOMAIN, + ), + current, + domain=DEMO_DOMAIN, + selector=CURRENT_SELECTOR, + ) + institution_header = broken_institution_sig.split(b"\r\n\r\n")[0].split( + b"DKIM-Signature:" + )[1] + forwarded = _sign( + b"DKIM-Signature:" + institution_header + b"\r\n" + forwarded_message, + forwarder, + domain=FORWARDER_DOMAIN, + selector="fwd", + ) + corpus.fixtures.append( + Fixture( + name="forwarded-broken", + filename="forwarded-broken.eml", + raw=forwarded, + expected_verified=False, + expectation="manual forward rewrote the body; the forwarder's signature " + "verifies and proves only that the forwarder handled it", + ) + ) + + # 5. Unknown domain: a perfectly valid signature from somebody we have no + # reason to believe. + stranger = _message( + "Your portfolio update", + _statement_html(date(2026, 7, 31), Decimal("99000000.00"), []), + datetime(2026, 8, 1, 8, 0, 0, tzinfo=timezone.utc), + FORWARDER_DOMAIN, + ) + corpus.fixtures.append( + Fixture( + name="unknown-domain", + filename="unknown-domain.eml", + raw=_sign(stranger, forwarder, domain=FORWARDER_DOMAIN, selector="fwd"), + expected_verified=False, + expectation="valid signature, but not from a known institution domain", + ) + ) + + return corpus + + +def load_corpus(outdir: Path) -> Corpus | None: + """Read a corpus written earlier, or None if there isn't one.""" + manifest_path = outdir / "manifest.json" + if not manifest_path.exists(): + return None + try: + manifest = json.loads(manifest_path.read_text()) + corpus = Corpus( + captured_dns=json.loads((outdir / "captured_dns.json").read_text()), + live_dns=json.loads((outdir / "live_dns.json").read_text()), + ) + for entry in manifest: + corpus.fixtures.append( + Fixture( + name=entry["name"], + filename=entry["file"], + raw=(outdir / entry["file"]).read_bytes(), + expected_verified=entry["expected_verified"], + expectation=entry["expectation"], + doc_kind=entry.get("kind", "statement"), + ) + ) + return corpus + except (OSError, KeyError, ValueError): + return None + + +def load_or_build(outdir: Path) -> Corpus: + """Generate the corpus once, then reuse it. + + Regenerating on every boot would mint fresh keys, which means fresh bytes, + which means fresh SHA-256s — and the pipeline would see every restart as a + pile of brand new evidence rather than the same twelve statements. Writing + it to the persistent disk keeps a deployment's fixtures stable across + redeploys, which is what makes re-seeding a no-op. + """ + existing = load_corpus(outdir) + if existing is not None and existing.fixtures: + return existing + corpus = build_corpus() + corpus.write(outdir) + return corpus + + +def main() -> None: + import sys + + outdir = Path(sys.argv[1] if len(sys.argv) > 1 else "var/fixtures") + corpus = build_corpus() + corpus.write(outdir) + print(f"wrote {len(corpus.fixtures)} fixtures to {outdir}") + for fixture in corpus.fixtures: + mark = "verifies" if fixture.expected_verified else "fails " + print(f" {mark} {fixture.filename}") + + +if __name__ == "__main__": + main() diff --git a/demo/seed.py b/demo/seed.py new file mode 100644 index 0000000..b02dfaf --- /dev/null +++ b/demo/seed.py @@ -0,0 +1,232 @@ +"""Stage the demo. + +The audience should see the magic, not the typing. This builds three records +that each make one argument, and prints the invite links so the demo starts on a +loaded page instead of an empty form. + + meridian-global-macro twelve signed months. Source-signed throughout. + northwind-partners eleven signed months and one typed into a spreadsheet. + Every metric drops to self-reported. One row does it. + evidence-lab the five ways DKIM breaks, each with a real signature + and a real verdict. +""" + +from __future__ import annotations + +import argparse +from datetime import date +from decimal import Decimal + +from sqlalchemy import delete, select + +from adapters import db +from adapters.chain import record_key +from adapters.config import settings +from core.metrics import Config, TWO_AND_TWENTY +from core.types import Channel +from demo.corpus import load_or_build +from worker import pipeline + +MERIDIAN = "meridian-global-macro" +NORTHWIND = "northwind-partners" +EVIDENCE_LAB = "evidence-lab" + +# The row that costs Northwind its grade. Deliberately the most recent month, +# because that is the one a manager is most likely to be missing when a +# deadline lands. +SELF_REPORTED_CSV = b"""as_of,nav,currency +2026-07-31,15488330.00,USD +""" + + +def _record(session, slug: str, name: str, strategy: str) -> db.Record: + existing = session.scalar(select(db.Record).where(db.Record.slug == slug)) + if existing is not None: + return existing + entity = db.Entity(name=name) + session.add(entity) + session.flush() + record = db.Record( + entity_id=entity.id, + slug=slug, + name=name, + strategy=strategy, + chain_key=record_key(slug), + viewer_salt=f"salt-{slug}", + ) + session.add(record) + session.flush() + return record + + +def _invite(session, record: db.Record, email: str, profile: str, token: str) -> str: + """Deterministic tokens so the demo links survive a reseed. + + Fine here and nowhere else: these records are synthetic. Real invites get + `secrets.token_urlsafe` in the API, and only the hash is ever stored. + """ + from api.app import token_hash + from datetime import datetime, timedelta, timezone + + digest = token_hash(token) + existing = session.scalar(select(db.Invite).where(db.Invite.token_hash == digest)) + if existing is None: + session.add( + db.Invite( + record_id=record.id, + viewer_email=email, + profile=profile, + token_hash=digest, + expires_at=datetime.now(timezone.utc) + timedelta(days=365), + ) + ) + session.flush() + return token + + +def seed(*, reset: bool = False, anchor: bool | None = None) -> dict[str, str]: + corpus = load_or_build(settings().fixture_dir) + pipeline.set_fixture_dns(corpus.captured_dns) + + db.create_all() + should_anchor = settings().anchoring_enabled if anchor is None else anchor + + session = db.SessionLocal() + try: + if reset: + for table in ( + db.Anchor, db.MetricRow, db.FindingRow, db.Snapshot, db.FactRow, + db.ExtractionRow, db.DkimVerdictRow, db.AccessEvent, db.Invite, + db.Document, db.Account, db.Record, db.Entity, + ): + session.execute(delete(table)) + session.commit() + + meridian = _record( + session, MERIDIAN, "Meridian Global Macro", + "Discretionary global macro, USD", + ) + northwind = _record( + session, NORTHWIND, "Northwind Partners", + "Systematic multi-asset, USD", + ) + lab = _record( + session, EVIDENCE_LAB, "Evidence Lab", + "Five ways a signature fails, each with a real verdict", + ) + + statements = [f for f in corpus.fixtures if f.name.startswith("statement-")] + confirmation = corpus.by_name("trade-confirmation") + + def load(record: db.Record, raw: bytes, filename: str, channel=Channel.EML_UPLOAD): + return pipeline.ingest( + session, + entity_id=record.entity_id, + record_id=record.id, + raw=raw, + channel=channel, + filename=filename, + ) + + changed: dict[str, bool] = {} + + # 1. Twelve signed months plus a signed trade confirmation. + outcomes = [load(meridian, f.raw, f.filename) for f in statements] + outcomes.append(load(meridian, confirmation.raw, confirmation.filename)) + changed[meridian.id] = any(not o.duplicate for o in outcomes) + + # 2. The same fund, with the last month typed instead of signed. + outcomes = [load(northwind, f.raw, f.filename) for f in statements[:-1]] + outcomes.append( + load(northwind, SELF_REPORTED_CSV, "july-nav.csv", Channel.CSV_UPLOAD) + ) + changed[northwind.id] = any(not o.duplicate for o in outcomes) + + # 3. The failure modes, kept in their own record so they grade + # themselves rather than dragging a real one down. + outcomes = [ + load(lab, corpus.by_name(name).raw, corpus.by_name(name).filename) + for name in ( + "archived-retired-selector", + "l-tag-appended", + "tampered-body", + "forwarded-broken", + "unknown-domain", + ) + ] + changed[lab.id] = any(not o.duplicate for o in outcomes) + + session.commit() + + config = Config(fee_model=TWO_AND_TWENTY) + for record in (meridian, northwind, lab): + existing = session.scalar( + select(db.Snapshot).where(db.Snapshot.record_id == record.id).limit(1) + ) + # A redeploy re-runs this script. Rebuilding regardless would stack + # a fresh snapshot and a fresh anchor every deploy, which would put + # gaps-free but meaningless entries in the very sequence that is + # supposed to mean something. + if existing is not None and not changed[record.id]: + print(f" {record.slug:24} unchanged, leaving snapshot {existing.seq} alone") + continue + + outcome = pipeline.rebuild( + session, record_id=record.id, config=config, anchor=should_anchor + ) + print( + f" {record.slug:24} seq={outcome.snapshot.seq} " + f"tier={outcome.snapshot.tier.slug:20} " + f"root={outcome.snapshot.root_hex[:18]}… " + f"metrics={len(outcome.metrics)} findings={len(outcome.findings)} " + f"anchor={outcome.anchor_status}" + ) + session.commit() + + tokens = { + MERIDIAN: _invite( + session, meridian, "allocator@example-endowment.org", + "full_detail", "demo-meridian-full", + ), + NORTHWIND: _invite( + session, northwind, "allocator@example-endowment.org", + "full_detail", "demo-northwind-full", + ), + EVIDENCE_LAB: _invite( + session, lab, "allocator@example-endowment.org", + "full_detail", "demo-evidence-lab", + ), + f"{MERIDIAN}-summary": _invite( + session, meridian, "cautious@example-fund.com", + "summary", "demo-meridian-summary", + ), + } + session.commit() + return tokens + finally: + session.close() + + +def main() -> None: + parser = argparse.ArgumentParser(description="Seed the PodShop Arena demo.") + parser.add_argument("--reset", action="store_true", help="wipe existing rows first") + parser.add_argument( + "--no-anchor", action="store_true", help="skip the on-chain anchor" + ) + args = parser.parse_args() + + print("seeding…") + tokens = seed(reset=args.reset, anchor=False if args.no_anchor else None) + + base = settings().public_base_url.rstrip("/") + print("\ndemo links:") + for label, token in tokens.items(): + print(f" {label:34} {base}/view/{token}") + print( + "\nEvery message above is synthetic and signed by a key generated at build " + "time.\nThe cryptography is real; the broker is not." + ) + + +if __name__ == "__main__": + main() diff --git "a/docs/PodShop Arena \302\267 Nobody Wants To Be Verified.pdf" "b/docs/PodShop Arena \302\267 Nobody Wants To Be Verified.pdf" new file mode 100644 index 0000000..3100868 Binary files /dev/null and "b/docs/PodShop Arena \302\267 Nobody Wants To Be Verified.pdf" differ diff --git a/docs/blitz-rules.md b/docs/blitz-rules.md new file mode 100644 index 0000000..c282e1e --- /dev/null +++ b/docs/blitz-rules.md @@ -0,0 +1,304 @@ +# Monad Blitz London — Rules, Judging, Demo, Tooling + +Everything binding for this project, merged from the official 60-slide hackathon brief +(not kept in this repo) and the organisers' written rules / +[Submission Process](https://monad-foundation.notion.site/Submission-Process-cc66367594f2837c898701aabd948402). +This file is now the only copy of the brief's contents we have. + +**Where the two sources disagree, it's flagged inline.** Ask an organiser rather than +guessing. + +--- + +## 0. The shape of the day + +> "An innovation sprint. Not a marathon. A one-day burst of creativity to rapidly +> prototype new ideas on Monad. A safe space to try bold, unconventional things. +> Failure and learning are celebrated." + +| Focus on | Worry less about | +| --- | --- | +| 💡 Novel mechanics & unique ideas | 🎨 Perfect UI / UX | +| 🧪 Exploring the limits of Monad | ✅ Extensive feature lists | +| ✨ Solving problems in new ways | 📊 Polished slide decks | + +--- + +## 1. Hard deadlines & logistics + +| Thing | Value | +| --- | --- | +| **Submission freeze** | **5:45 PM** | +| Portal (tokens, submission, voting) | [blitz.devnads.com](https://blitz.devnads.com) | +| Pitching order | **Determined by submission order** | +| Demo length | 3 minutes | +| Voting window | During presentations + **15 min after the final demo** | +| Wi-Fi | `Encode Club` / `Acce551969` | + +**Submission order sets pitching order.** Submitting early buys a choice about where in +the running order you land, so decide deliberately rather than by accident. + +--- + +## 2. Eligibility — five gates to qualify for pitching + +Every project must clear all five: + +1. **Fork the starter repo.** Build from a fork of `monad-blitz-london`. +2. **Ship a proper README.** Clear setup, what it does, and how to run it. +3. **Deploy on Monad.** Live on Monad **Testnet or Mainnet**. +4. **Deploy during the Blitz.** Contracts must be deployed within the event timeline. +5. **Live on the web.** A working, deployed app — not slides alone. + +Miss any one and the project doesn't get to pitch, however good it is. Gate 4 is the +only one that can't be fixed retroactively. + +> ⚠️ **Conflict:** the written rules say the project must be on **Monad Testnet**; the +> brief's eligibility slide allows **Testnet or Mainnet**. Testnet satisfies both, so +> default to Testnet. + +--- + +## 3. Rules of engagement + +**01 Start fresh.** +All project code must be written today. No existing personal projects. Standard +libraries are fine. The written rules add: you cannot submit existing projects, fork +existing codebases beyond standard libraries/boilerplates, or continue personal +projects already started. (Forking the `monad-blitz-london` starter is required and +doesn't count.) + +**02 Ship live deployments.** +Every submission must be deployed with a working, accessible demo link. **Locally +hosted projects will be disqualified.** + +**03 Public repositories.** +Code lives in a public GitHub repo for everyone to see and learn from. + +**04 Be ready to demo in three minutes.** +Pitching order is determined by submission order. Slides are optional. The demo is the +point. + +**Team size.** Maximum 4 members. *(From the written rules; the brief doesn't mention +it.)* + +**Innovate, don't just replicate.** Direct clones of existing apps without significant +innovation or a Monad-specific twist are strongly discouraged. The question to answer: +"What new problem am I solving, or how am I solving an old problem in a radically new +way on Monad?" + +**Planning ahead is encouraged.** Coding starts at the Blitz, but research, +brainstorming, and arriving with a concept are explicitly welcomed. + +--- + +## 4. Demos & judging + +Three minutes. The room decides. + +**The demo** + +- Show the live build. Lead with the live demo and the core innovation. +- Your audience is everyone in the room — fellow builders, not VCs or non-technical + judges. Impress developers. +- Slides not required. Just present the demo. + +**How votes work** + +- Vote for the most innovative, technically interesting, or inspiring project. +- **Results are decided by a mix of judge and participant votes, weighted 50% each.** +- Teams cannot vote for their own project. +- After the 15-minute window closes, scores are tallied automatically and winners are + announced. + +> ⚠️ **Conflict:** one brief slide says "No official judges. The room decides," while +> the detail on the same slide says results are **50% judge / 50% participant**. The +> written rules describe it as purely community-driven. Treat it as 50/50 — that's the +> most specific claim, and it means a demo has to land with both a peer audience and a +> judge. + +**Voting criteria** + +- **Novelty & originality** — a truly new idea, a unique application of technology, or + a fresh approach to an existing problem? +- **Innovative mechanics** — clever or novel mechanics, smart contract designs, or user + interactions, especially ones that leverage Monad's potential? +- **Problem-solving** — does it creatively address a real or interesting challenge for + consumer applications? +- **Learning & experimentation** — willingness to experiment, push boundaries, and + learn, even if not every aspect is polished? + +**The spirit of the vote.** Not the most polished or complete application — the new +idea, the unique approach, the thing everyone can learn from. Vote for what excited you +most with its ingenuity. + +--- + +## 5. Presentation tips from the organisers + +Filed under "BONUS · VERY, VERY IMPORTANT" in the brief. + +**Tip 01 · Slides — fewer slides, more demo.** +Use slides to state the problem in one breath, then get to the live demo. The room came +to see your build, not your deck. + +**Tip 02 · UI — don't ship vibe-coded UI.** +Give the LLM a brand kit: typeface, colour, components. *"A real design beats a default +Tailwind blob."* + +> Note the tension with "worry less about perfect UI/UX." Read together: don't spend the +> day on polish, but don't hand the room an unstyled default either. One coherent brand +> kit applied consistently, then stop. + +**Tip 03 · Demo flow — pre-fill your forms, stage your demo.** +The judge sees the magic, not the typing. Better yet: have an example pre-created. + +**From the written rules, also:** + +- Practise the timing — 3 minutes is short. +- Focus on the core innovation; don't show every feature. +- Prepare for demo gremlins: screenshots of key flows, a short fallback recording, and + a stable deployment. +- Be clear on "what" and "why" fast, then get to the demo. + +--- + +## 6. Prizes + +Tracks, bounties, and overall prizes are in the info pack — check it **before** scoping +the build, since a bounty may be worth aiming at deliberately. + +| Place | Prize | +| --- | --- | +| 🥇 1st | 1200 USD | +| 🥈 2nd | 800 USD | +| 🥉 3rd | 500 USD | +| 🥉 4th | 500 USD | + +> 📌 **Missing:** we don't have the info pack itself, so the tracks and bounties are +> unknown. Get it — it's the one input most likely to change scope. + +--- + +## 7. Monad cheat-sheet + +Useful for the "how you built it" and "why this needs Monad" parts of the pitch. + +**By the numbers** + +| Property | Monad | Ethereum L1 | +| --- | --- | --- | +| Throughput | 500M gas / sec | 5M gas / sec | +| Block time | 0.3 s | 12 s | +| Finality | 0.6 s | ~13 min | +| Sustained TPS | 10,000 | ~28 | +| Active validators | 200 | ~27,000 / block | + +**The six architecture primitives** (no contract changes required — existing Solidity +deploys unchanged): + +*Execution* + +- **Asynchronous execution** — consensus doesn't wait for execution; each gets a full + block to work. (Restaurant where front of house never waits for the kitchen.) +- **Optimistic parallel execution** — run transactions at the same time; re-run only + the few that clash. (A team editing one shared doc, each in their own section.) +- **MonadDB** — a database built for blockchain state, so reads stop being the + bottleneck. + +*Consensus & runtime* + +- **MonadBFT** — validators agree in one round, so blocks are final in 600 ms. +- **RaptorCast** — blocks split into pieces and spread across the network in parallel. +- **JIT compilation** — contract bytecode compiles to native code once, then runs from + cache. + +**What 600 ms finality unlocks** (the brief's own list of what's new on Monad): + +- Real-time, fully on-chain apps +- AI agents with on-chain state +- High-frequency gaming and DeFi +- Payment rails that feel like Web2 +- Computationally heavy ZK and privacy + +Same as Ethereum: Solidity and the same compiler, Foundry / Hardhat / viem / ethers, +the same EVM model (accounts, gas, tx ordering), the same JSON-RPC and WebSocket +surface. + +--- + +## 8. Tooling — don't rebuild what's shipped + +Full catalog: [docs.monad.xyz/tooling-and-infra](https://docs.monad.xyz/tooling-and-infra). + +**Frameworks** + +- **viem 2.40+** — native Monad testnet and mainnet support, same client patterns. +- **Monad Foundry** — a Foundry fork with Monad-native EVM execution, staking + precompile support, human-readable trace decoding, and gas during simulation. + Install: `curl -L https://foundry.category.xyz | bash` + +**Categories and named options** (logos in the deck; names below are the ones legible — +check the catalog for the rest) + +- **Indexers** — Envio, Goldsky, Allium, Moralis +- **Oracles, wallets, RPC** — Chainlink, Pyth, Alchemy, QuickNode, MetaMask +- **Account abstraction** — Privy, Para, Mera (marked *free*) +- **Bridges & swaps** — see catalog + +**Agentic payments & agent identity** + +- **x402 on Monad** — free facilitator hosted by Monad. + [docs.monad.xyz/guides/x402-guide](https://docs.monad.xyz/guides/x402-guide) +- **MPP on Monad** — `@monad-crypto/mpp` +- **ERC-8004 on Monad** — on-chain identity for AI agents + +**Mobile** — React Native and PWA templates for native mobile apps on Monad. + +**AI-assisted building** + +- [skills.devnads.com](https://skills.devnads.com) — coding-assistant skills to build + Monad apps in a few prompts: built-in testnet faucet, contract dev & deploy, frontend + dev & deploy, indexer deployment. +- [app.monad.xyz/agents](https://app.monad.xyz/agents) — agent-readable skills from + ecosystem projects; point an agent at a protocol and let it quote, build, and prepare + transactions. DeFi: Uniswap, Morpho, Balancer, Kuru, Clober. Launchpads & tokens: + Nad.fun. Gaming & prediction: DevFun, Blinq.fi. + +> Disclaimer carried from the deck: these skills are maintained by ecosystem projects +> and are **not audited** by Monad Foundation. + +**Docs** + +- [docs.monad.xyz/developer-essentials](https://docs.monad.xyz/developer-essentials) — + almost everything needed to build. +- `docs.monad.xyz/llms-full.txt` — comprehensive single-file source for AI coding + assistants. **Feed this to the agent instead of guessing at Monad specifics.** +- [developers.monad.xyz](https://developers.monad.xyz) — docs, events, Discord. +- [github.com/monad-developers](https://github.com/monad-developers) — examples, + starter kits, toolchain changes. +- [gmonads.com](https://gmonads.com) — live validator / block-time view (good B-roll for + a demo). + +--- + +## 9. Submission process + +1. Fork [`monad-developers/monad-blitz-london`](https://github.com/monad-developers/monad-blitz-london). +2. Give the fork your project name and a one-liner description, fork the `main` branch, + click **Create Fork**. +3. In your fork, change anything and everything — add project code, create branches, + fill in `README.md`. +4. Submit at [blitz.devnads.com](https://blitz.devnads.com) — also where you claim + tokens and vote. **Freeze at 5:45 PM.** + +This repo (`pod-shop-arena`) is that fork. + +--- + +## 10. After the Blitz + +- **MOST** (Monad Open Source Track) — contribute to open source, earn AI credits. +- **AI Blueprint** — resources, infra, and support to build and scale AI apps on Monad. +- **Delta V** — members-only community for founders building on Monad. +- **BuildAnything.so** — go from vibe coder to production-ready apps. diff --git a/docs/build-plan.md b/docs/build-plan.md new file mode 100644 index 0000000..673c5e3 --- /dev/null +++ b/docs/build-plan.md @@ -0,0 +1,325 @@ +# PodShop Arena — build plan + +How the app actually works, and the order we build it in. + +Companion docs: [`strategy.md`](./strategy.md) is why, this is how. The deck is +[`deck/index.html`](./deck/index.html). + +--- + +## Context + +A track record is worth nothing if the person it flatters typed it. Every existing fix +asks the institution to cooperate — an API, a data feed, a signed letter — and Goldman is +not going to cooperate. So the product has to work on evidence the institution *already* +emits without agreeing to anything, and it has to be honest about how strong that +evidence is. + +Two things are being built, and conflating them is the main way this goes wrong: + +**Authenticity** — did these bytes come from the institution? That's cryptography. A DKIM +signature on a statement email is the institution's own key vouching for the content, and +they signed it years ago without thinking about it. + +**Integrity over time** — was this claimed before the claimer knew how the month would +turn out? That's the chain. Anchoring doesn't stop a lie; it stops a *later* lie. Commit +every ingest and backfilling dies, because the sequence is public and contiguous. + +Everything below serves one of those two. If a feature serves neither, it's in §9. + +--- + +## 1. The trust ladder + +The tier is a property of **evidence**, never of a connector or a customer. It is +computed, never asserted. + +| Tier | What it means | How we get it | +| --- | --- | --- | +| `source_signed` | The institution's own key verifies the bytes | DKIM on statement/confirmation email; signed PDF; zkTLS session transcript | +| `aggregator_attested` | A named third party pulled it | IBKR Flex, administrator SFTP, Plaid | +| `platform_observed` | We saw it. Our word only | Extension or agent capture | +| `self_reported` | Nobody vouches | CSV, manual entry | + +Propagation rules, enforced in the pure core: + +- A **fact** inherits the tier of the document it came from. +- A **metric** carries `min(tier)` over every fact that fed it. One CSV row in a + twelve-month TWR drags the whole number to `self_reported`. That is correct and it is + the point. +- Nothing is displayed without its tier next to it. + +We are not building `platform_observed`. An agent logging in as the user buys no stronger +signature than Plaid does, and costs us credential custody, most brokers' ToS, and the +"read-only, never custody" principle. The rung exists in the model so the UI can grade +imported data honestly, not so we can ship a scraper. + +--- + +## 2. Authenticity: the DKIM path + +The differentiator, so it gets built properly. + +### What DKIM actually gives us + +A `DKIM-Signature` header signs a set of headers plus `bh=`, a hash of the body — MIME +attachments included. The public key lives in DNS at `._domainkey.`. +Verify it and you have proved: *these exact bytes left this domain*. + +### The five things that break it + +1. **Byte exactness.** Verification runs against the raw RFC822 bytes as received, CRLF + and all. We store the original blob and verify against *that*, never against anything + re-serialized by a mail library. Any pipeline stage that normalizes line endings + silently destroys the proof. +2. **Key rotation.** Selectors get retired and leave DNS. A signature we can verify today + is unverifiable in a year. **So we capture the DNS TXT record at first verification, + store it with the document, and include its hash in what we anchor.** That timestamped + capture is what makes the proof durable — and it's a concrete reason the chain earns + its place. +3. **The `l=` tag.** If present, only the first N bytes of the body are signed and + content can be appended below. Treat `l=` as a hard fail, not a warning. +4. **Multiple signatures.** A forwarded mail carries the forwarder's DKIM too. We only + care about a signature whose `d=` is the institution's domain, matched against a + registry of known institution domains. Gmail's signature on a forward proves Gmail + handled it, which is worth nothing to an allocator. +5. **Forwarding.** Manual "Forward" from a mail client rewrites the body and breaks the + signature. A server-side auto-forward *filter* usually preserves it. + +### Intake channels + +- **Per-entity ingest address** (`u_@ingest.podarena.app`), and the manager sets a + filter to auto-forward from their broker/administrator. Primary path. +- **`.eml` upload.** Always works, always intact. Fallback and the honest default. + +### Authenticity is not interpretation + +DKIM proves the bytes. It does not prove that "NAV: 41,207,338.22" means what our parser +thinks it means. So the two are modeled separately and both are shown: + +- The signature verdict is cryptographic and final. +- The **extraction** is our code — versioned, pure, and reproducible. Anyone can pull the + original signed document and check our reading. + +Extractors are `(institution, doc_type, version) -> Fact[]`, pure functions with the +version recorded on every fact. When we fix a parser we bump the version and re-derive; +the original evidence never changes. + +--- + +## 3. Architecture + +Dependency runs one way. `core` is pure Python — no I/O, no clock, no network — because +that is what makes results reproducible and the determinism testable. + +``` +contracts/ Foundry. TrackRecordRegistry on Monad. +core/ Pure. Domain types, tier algebra, extraction interfaces, + metrics engine, Merkle. Depends on nothing. +adapters/ DNS, blob store, chain client, mail intake, IBKR Flex, SFTP. +api/ FastAPI. Auth, routes, and the fence (server-side projection). +worker/ arq tasks. The ingest pipeline. +web/ Vite + React + TanStack. viem for on-chain reads. +``` + +**Backend** — Python 3.12, FastAPI, Pydantic v2, SQLAlchemy 2.0 + Alembic, `uv` for deps, +arq + Redis for the pipeline, `dkimpy` + `dnspython` for signatures, `web3.py` for +anchoring. + +**Frontend** — React + Vite, TanStack Router / Query / Table / Form, **viem 2.40+** for +reading anchors straight from Monad. Charts are hand-rolled inline SVG so the picture and +the numbers can never disagree. + +**Data** — Postgres 16 for structured state. Raw documents go to S3/R2 as +content-addressed write-once blobs, encrypted per entity. Never in the database. + +Two rules that are load-bearing rather than stylistic: + +- **Money is `BIGINT` minor units plus a currency code.** Never a float, anywhere. +- **Metrics compute in `Decimal`** with an explicit quantization step, so the same inputs + give the same bytes on every machine. Float non-determinism would break the anchor. + +--- + +## 4. Schema + +``` +entity the manager or fund +account a book at an institution; belongs to an entity +institution name, domains[], known DKIM selectors, doc types we can parse + +document sha256, blob_key, received_at, channel, institution_id (write-once) +dkim_verdict document_id, d_domain, selector, verified, l_tag_present, + dns_txt_record, dns_captured_at, algo, failure_reason +extraction document_id, extractor_version, status, payload jsonb +fact account_id, document_id, as_of, kind, instrument, qty, + price_minor, amount_minor, currency, tier + +snapshot record_id, seq, fact_root, metrics_hash, methodology_version, + extractor_versions jsonb +metric_result snapshot_id, key, value, formula, inputs, tier +finding snapshot_id, kind, severity, detail (reconciliation conflicts) + +anchor snapshot_id, chain_id, tx_hash, block_number, root, seq, confirmed_at +invite record_id, viewer_email, profile, nda_version, token_hash, + expires_at, revoked_at +access_event invite_id, action, at, ip, ua, chain_tx (append-only) +``` + +`document` and `access_event` are append-only by convention *and* by a database trigger +that rejects `UPDATE`/`DELETE`. An audit trail you can quietly edit is not an audit trail. + +--- + +## 5. The ingest pipeline + +One orchestrator, numbered steps, each pure where it can be: + +1. **Receive** — raw bytes land from mail intake or upload. Hash, write blob, insert + `document`. Identical content is a no-op. +2. **Verify** — run DKIM against the raw bytes. Capture the DNS key. Write `dkim_verdict`. + Assign the tier here and nowhere else. +3. **Extract** — pick the extractor by `(institution, doc_type)`, run it, store the + payload and the version. +4. **Normalize** — extraction payload becomes `fact` rows, each pointing at its document + and inheriting its tier. +5. **Reconcile** — where two sources cover the same period, they must agree. A mismatch + writes a `finding`, it does not throw. Allocators care more about the discrepancies we + surface than the ones we hide. +6. **Compute** — the pure metrics engine over the ordered fact series. Linked TWR, + Modified Dietz across flows, max drawdown, volatility, Sharpe, Sortino, Calmar, VaR. + Net *and* gross side by side, with the fee model an explicit input, because the SEC + Marketing Rule requires it. +7. **Commit** — build the Merkle tree, anchor the root on Monad, store the `anchor` when + the receipt confirms. + +### What goes in the tree + +Leaves, canonically serialized and sorted: + +- each document's content hash +- each DKIM verdict, including the hash of the captured DNS key record +- each fact's canonical hash +- the metrics result hash, with methodology version and every extractor version + +So the anchor commits to the evidence, to the proof that the evidence was signed, to our +reading of it, and to the code version that did the reading. All four, or the commitment +is a fig leaf. + +--- + +## 6. Contract + +```solidity +contract TrackRecordRegistry { + event Anchored(bytes32 indexed recordId, bytes32 root, uint64 seq, uint64 ts); + event Accessed(bytes32 indexed recordId, bytes32 viewerCommitment, uint8 profile, uint64 ts); + + function anchor(bytes32 recordId, bytes32 root) external returns (uint64 seq); + function logAccess(bytes32 recordId, bytes32 viewerCommitment, uint8 profile) external; + function head(bytes32 recordId) external view returns (bytes32 root, uint64 seq, uint64 ts); +} +``` + +Storage keeps only the head and a counter; history lives in events, which is far cheaper +and just as verifiable. `seq` is strictly monotonic per record, so a gap in the sequence +is visible to anyone reading the log — that is the anti-backfill mechanism. + +`viewerCommitment` is `keccak(viewer_id ‖ per_record_salt)`. The chain shows *that* a view +happened and when, without publishing who. The salt is disclosed to the record owner only. + +**Why Monad.** Anchoring every ingest and every view is only sensible at 0.3 s blocks and +0.6 s finality. On a twelve-second chain you batch nightly, and the moment you batch you +have proved the day rather than the moment — which is exactly the property worth having. + +--- + +## 7. The fence + +- **Invite** is bound to an identity and a disclosure profile, single-use, expiring, + revocable. Not a public URL with a long random string. +- **NDA gate** renders before anything else; consent is written to `access_event` before + the first byte of data is served. +- **Disclosure profile** is applied as a **server-side projection**. The filtered fields + never enter the response, so there is no client-side toggle to defeat. This is the one + security property to hold absolutely: hidden data does not cross the wire. +- **Watermark** with viewer identity and timestamp, composited server-side into any + export as well as rendered client-side. +- **Access** is written to the audit log and committed on-chain. + +Profiles: `summary` · `ratios_and_risk` · `full_detail` · `full_plus_positions`. +Default is metrics-only. Positions are opt-in, per viewer, every time. + +--- + +## 8. Independent verification + +The property we are actually selling is that **our API is never in the trust path.** In +the viewer's browser: + +1. Read the anchored root from Monad with viem, through their RPC, not ours. +2. Fetch the Merkle path for the leaves the profile permits. +3. Re-fold and compare locally. Show `Verified locally` only when local math agrees. +4. Download any original signed document and re-run DKIM independently — we ship a + standalone verifier that takes an `.eml` plus the captured DNS record and needs no + PodShop Arena account. + +If we shut down tomorrow, an allocator holding the documents can still prove everything. +That is the standard to build to. + +--- + +## 9. Order of work + +Each phase ends with something demonstrable end to end. + +**Phase 0 · Spine.** Monorepo, `core` with domain types and the tier algebra, Postgres + +Alembic, FastAPI skeleton, contract deployed to Monad Testnet, one route that anchors a +hard-coded root and reads it back. Proves the riskiest seam first. + +**Phase 1 · Authenticity.** `.eml` upload, DKIM verification with DNS key capture, blob +store, the first extractor for one real institution, facts landing in Postgres with a +computed tier. This is the differentiator, so it comes before anything pretty. + +**Phase 2 · Numbers.** The deterministic metrics engine, net and gross, provenance on +every value, reconciliation findings, and the determinism test that asserts same inputs → +same hash → same root. + +**Phase 3 · Commitment.** Full Merkle tree over the four leaf classes, per-ingest +anchoring, the anchor timeline in the UI, in-browser re-folding, and the standalone +verifier. + +**Phase 4 · The fence.** Invites, NDA gate, server-side projections, watermarking, audit +log, on-chain access commitments. + +**Phase 5 · Reach.** Mail intake with per-entity addresses. IBKR Flex. Administrator +SFTP. Each drops in behind the existing evidence interface. + +**Phase 6 · Later.** zkTLS for portals with no email and no feed. ZK proofs so a manager +proves 22% without showing a position. GIPS composites. Attribution. + +### Not building + +Retail broker connectors, discovery marketplace, reputation graph, peer cohorts, hiring +workflow, and any form of agent-logs-in scraping. The first five have no buyer today. The +last one is a liability dressed as a feature. + +--- + +## 10. How we know it works + +- **Determinism test.** Same fixture inputs produce the same fact hashes, the same metric + values, and the same Merkle root, across machines and runs. This is the contract that + makes everything else meaningful, so it is the test that must never be skipped. +- **DKIM fixtures.** A corpus of real signed messages: valid, `l=`-tagged, key-rotated, + body-tampered by one byte, forwarded-and-broken. Each has an expected verdict. +- **Tamper test.** Flip a byte in a stored document; the verdict must fail, the tier must + drop, the recomputed root must differ from the anchored one, and the UI must say so + without being asked. +- **Projection test.** For every disclosure profile, assert the serialized response + contains no field the profile forbids. Test the wire, not the component. +- **Anchor round-trip.** Anchor on Monad Testnet, then verify in a browser using a public + RPC we do not control. +- **Cold-verify drill.** Hand someone the `.eml` files, the captured DNS records, and the + contract address, with no access to our API. They should be able to prove the record + themselves. If they can't, the design has failed regardless of what the tests say. diff --git a/docs/deck/index.html b/docs/deck/index.html new file mode 100644 index 0000000..ac0a3e9 --- /dev/null +++ b/docs/deck/index.html @@ -0,0 +1,458 @@ + + + + + +PodShop Arena · Platform Overview + + + + + + +
+ + +
+
+
+
+
+ POD ARENA +
+
Invite-only · Confidential
+
+ +
+
Verified, private track records
+

Proof over
PDFs.

+

+ The platform where portfolio managers and funds prove real performance. Every number comes + from read-only source data, gets time-stamped so it cannot be quietly edited, and is shared + only with the people they invite. +

+
+
+
+ + +
+
+
The problem
+

Track records are self-reported PDFs.

+
+
+
+

+ They are cherry-picked, backfilled, and impossible for an outsider to check. Proving your + edge today means mailing strangers your brokerage statements, or asking them to trust a deck. +

+

+ An allocator cannot tell a real 22% from a flattering one. A manager cannot prove theirs + without handing over the positions that are the whole edge. The document is the weakest + link in the market. +

+
+
+

Cherry-picked windows

Start and end dates chosen after the fact to flatter the curve.

+

Backfilled and hand-typed

The numbers are entered by the person they flatter, with nothing tying them to source.

+

Positions exposed to prove it

The only proof on offer is handing over the very holdings that are the edge.

+
+
+
PodShop Arena · Confidential02
+
+ + +
+
+
What PodShop Arena is
+

Performance you never have to take on faith.

+

Six principles hold the product together. Each one is enforced by code, not by a promise.

+
+
+

Source of truth only

Every metric comes from read-only institutional data. Nobody types in a performance number, ever.

+

No made-up metrics

One methodology, aligned with GIPS. The same inputs always produce the same numbers, so anyone can reproduce them.

+

Exclusivity fence

Nothing is public. Access is by invitation, scoped to the least a viewer needs, and can be revoked or set to expire.

+

Read-only, never custody

We read; we never hold. The connector exposes one verb, pull(). There is no button that could trade or withdraw.

+

Verifiable, not asserted

Snapshot hashes are anchored on Monad. The viewer re-runs the check in their own browser instead of trusting ours.

+

Compliance from day one

KYC and KYB, accredited-investor gating, and an SEC Marketing-Rule posture are part of the design.

+
+
PodShop Arena · Confidential03
+
+ + +
+
+
How it works
+

From a live account to a proof.

+

The riskiest part is already built and runs start to finish. Every number traces back to a hashed snapshot.

+
+
+
+
01
Connect

Read-only link to a broker, Plaid, or a fund-admin feed. Nothing can trade or withdraw.

+
+
02
Snapshot & hash

The raw pull is written once to a content-addressed vault. <sha256>.json

+
+
03
Compute

A deterministic engine derives TWR, IRR, Sharpe, Sortino, Calmar, and VaR.

+
+
04
Anchor

Every pull's Merkle root is written to Monad. Only the root is published; raw data stays in the vault.

+
+
05
Share

Behind the fence: invited viewers, scoped disclosure, an NDA, a watermark, and a full log.

+
+
+ Built for + Emerging PMs proving a record· + Funds raising the next fund· + Allocators & LPs running diligence· + Employers vetting a PM's book +
+
+
PodShop Arena · Confidential04
+
+ + +
+
+
Why this needs Monad
+

Daily anchoring is a workaround.

+

Every provenance system on a slow chain batches, because blocks are slow and blockspace is dear. Batch once a night and you have proved the day, not the moment. So we stopped batching.

+
+
+
+
+
1
Anchor every pull, not every night. Three-tenths-of-a-second blocks and 600 ms finality put the root on-chain before the sync has finished. The window an editor could work in drops from twenty-four hours to under a second.
+
2
The audit log is the chain. Every view behind the fence is its own transaction: who opened the record, when, under which disclosure profile. Append-only because it is a block, not because we promised.
+
3
The viewer checks it without us. They read the root straight off Monad and re-fold the proof locally. Our API is never in the trust path, and verifying costs them nothing.
+
+
+ + + + + + + + +
Ethereum L1Monad
Block time12 s0.3 s
Finality~13 min0.6 s
Throughput5M gas/s500M gas/s
So you anchornightly, in a batchevery pull, every view
+

That last row is the product. Per-view anchoring at twelve-second blocks would cost more than the subscription and settle after the meeting had ended.

+
+
+
+ Monad Testnet + TrackRecordRegistry · {{CONTRACT_ADDRESS}} + live · {{LIVE_URL}} +
+
+
PodShop Arena · Confidential05
+
+ + +
+
+
The UX flow
+

Two sides of the fence, one verified truth.

+
+
+
+
Manager or fund, the owner
+
+
Sign in

An invite-only account: PM, fund, or allocator.

+
+
Connect a source

Read-only. It pulls about 18 months of data.

+
+
Verified record

Metrics, curve, methodology, on-chain proof.

+
+
Invite behind the fence

Pick a disclosure profile and an expiry.

+
+
+
+
Allocator or LP, the viewer
+
+
Open the secure link

An identity-bound invite, not a public URL.

+
+
Accept the NDA

Consent is recorded to the audit trail.

+
+
Watermarked view

Only what the owner permitted, stamped with viewer identity.

+
+
Verify it yourself

Re-fold the Merkle proof in your own browser.

+
+
+
+
PodShop Arena · Confidential06
+
+ + +
+
Screens · The owner's home

A record that computes itself.

+

From a solo book to a multi-strategy shop, every record is verified the same way.

+
+
+
podarena.app/
Portfolio manager dashboard
+

A solo manager. Metrics pulled from source, tagged Live-Connected.

+
+
+
podarena.app/ · Meridian Capital
Fund dashboard with four funds
+

A multi-strat GP. Each fund is its own verified record.

+
+
+
PodShop Arena · Confidential07
+
+ + +
+
Screen · The verified record

One long, verifiable page.

+
+
podarena.app/records/…
Full verified record page
+
+
1
Source attestation strip. Institution, read-only access method, last sync, period, and methodology version, all in one line.
+
2
Verified performance. Net and gross shown side by side, which the Marketing Rule requires.
+
3
Equity curve. The same linked-TWR series the engine produced, so the picture cannot drift from the numbers.
+
4
Methodology and all metrics. Every value shows the formula behind it and the inputs that fed it.
+
5
Verification, fence, and audit. Proof, sharing controls, and the full access log all live on the page.
+
+
+
PodShop Arena · Confidential08
+
+ + +
+
Verification · the moat

Proof you check yourself.

+
+
On-chain verification panel, verified locally
+
+
1
Hashed, then anchored. Each snapshot is hashed and its root written to Monad on the same pull — a timestamp nobody can backdate.
+
2
The proof reveals nothing. Only the root is published, so positions never leave the vault.
+
3
Recomputed in the browser. The viewer's browser rebuilds the root from the proof and compares. When it matches, it reads Verified locally.
+
+
Every metric is tagged by how strong its source is:
+
+ live_connected + + zk_proven + + third_party_attested + + document_ingested + + unverified +
+

Next on this axis: zero-knowledge proofs, already modeled as the zk_proven tier, so a manager can prove a 22% return without showing a single position.

+
+
+
PodShop Arena · Confidential09
+
+ + +
+
The exclusivity fence

You decide who sees what.

+
+
+
records/… · share
Exclusivity fence share panel
+

The owner sets the terms. Named viewer, disclosure profile, expiry.

+
+
+
shared/… · NDA gate
NDA gate the viewer sees first
+

The viewer signs first. Nothing renders until the NDA is accepted, and the consent is logged.

+
+
+
+ summaryratios & riskfull detailfull + positions + reveal dollar amounts, or keep it to weights. Revoke anytime. +
+
PodShop Arena · Confidential10
+
+ + +
+
Screen · Behind the fence

What the allocator actually sees.

+
+
podarena.app/shared/… · Dana Whitfield
Watermarked shared record view with positions
+
+
1
Watermarked to the viewer. Their name, email, and the time are tiled across every screen, so any leak is traceable.
+
2
Only what was permitted. This profile reveals positions with market value. Another viewer would get ratios and nothing more.
+
3
Verified from their side too. The allocator runs the same proof and gets Verified locally.
+
4
The view is logged. The page says so, and it means it: the view lands in the owner's append-only audit trail.
+
+
+
PodShop Arena · Confidential11
+
+ + + +
+
Status · built at Monad Blitz London

What is real, and what is scaffolding.

+

One day of building. The pipeline runs end to end and anchors to Monad Testnet. Everything still stubbed sits behind a stable interface, so the real thing drops in without a caller noticing.

+
+
+
Built today, running live
+
+
Read-only pull into an immutable, content-addressed snapshot vault
+
A deterministic metrics engine on a versioned methodology, determinism unit-tested
+
TrackRecordRegistry on Monad Testnet — every snapshot root anchored on the pull
+
In-browser proof: the viewer re-folds the Merkle path against the on-chain root
+
The fence: NDA gate, scoped disclosure, watermark, and an on-chain view log
+
+
+
+
Stubbed on purpose, and where it goes
+
+
The connector is a seeded, deterministic mock. Real Plaid, IBKR Flex, and fund-admin SFTP feeds drop in behind the same pull().
+
Zero-knowledge proofs, already modeled as the zk_proven tier. No circuit yet.
+
Identity and security: KYC/KYB, passkeys and MFA, encryption at rest.
+
GIPS composites, attribution, and the third-party attestor role.
+
The demand side: allocator discovery, data rooms, and the arena where managers compete for capital.
+
+
+
+
PodShop Arena · Confidential12
+
+ + +
+
+
+
PodShop Arena
+

Claims fade.
Proof lasts.

+

+ A number a skeptic can check to the basis point, while the manager's positions stay theirs. + That is the whole product. +

+
+
+
+ POD ARENA +
+
+
+ +
+ + diff --git a/docs/deck/screenshots/README.md b/docs/deck/screenshots/README.md new file mode 100644 index 0000000..2800118 --- /dev/null +++ b/docs/deck/screenshots/README.md @@ -0,0 +1,28 @@ +# Deck screenshots + +`../index.html` loads seven images from this folder. Until they exist, slides 07–11 render +with broken-image icons — which is the one thing that would make the deck unusable live. + +Capture each at **2× device pixel ratio**, browser window ~1440 wide, and crop to the +region named. Filenames are exact; the deck references them by name. + +| File | Slide | What it has to show | +| --- | --- | --- | +| `02-owner-dashboard-pm.png` | 07 | Solo manager's dashboard. Metrics visible, `live_connected` badge visible. | +| `03-fund-dashboard.png` | 07 | Multi-strat GP with four funds, each its own record. | +| `04-record-full.png` | 08 | The full record page, top to bottom — attestation strip, net/gross, equity curve, methodology, verification. Tall crop; the deck clips it to 432px. | +| `06-verification-verified.png` | 09 | The verification panel reading **Verified locally**, with the Monad root hash. | +| `07-share-fence.png` | 10 | Share panel: named viewer, disclosure profile, expiry. | +| `08-shared-nda-gate.png` | 10 | The NDA gate a viewer hits before anything renders. | +| `09-shared-view-watermarked.png` | 11 | Watermarked shared view with positions, watermark legible. | + +Three notes on the captures themselves: + +- **Use staged demo data, not real numbers.** Slide footers say *Confidential*; don't put + a real manager's book in a file that ships in a public repo. +- **The watermark in `09` needs to be readable.** It's the whole point of the slide. If + the screenshot compresses it into grey mush, the callout claims something the image + doesn't show. +- **`06` should show a real Monad Testnet root**, not a placeholder hash. Slide 05 puts + the contract address on screen; a room of developers will check whether the hash on + slide 09 is the same one. diff --git a/docs/strategy.html b/docs/strategy.html new file mode 100644 index 0000000..e4340ff --- /dev/null +++ b/docs/strategy.html @@ -0,0 +1,331 @@ + + + + + +PodShop Arena · Nobody Wants To Be Verified + + + + + + +
+ + +
+ +
+
+
PodShop Arena
+

Nobody wants to be verified

+
+
Strategy memo
8 August 2026
Internal · Draft
+
+
+ + +
+
01

Demand

+

Portfolio managers want money. Allocators want to spend less time on diligence. Verification is the toll booth between them, and a toll booth is an excellent thing to own.

+

SOC 2 is the same shape. Nobody wants the report. It costs six figures, improves nothing a customer can see, and companies buy it because their buyers stopped signing without one. That’s what Vanta built on.[1]

+
The allocator creates the demand.
The manager pays the bill.
+

We are not going to persuade managers that verification is good for them. The job is to get a small number of allocators people take seriously to say four words: send it through PodShop Arena. Then we sell everyone in their pipeline the ability to comply.

+
+
Blockchain
+
    +
  • Keep the anchoring and the ZK proofs. They are cheap and they work.
  • +
  • Sell one sentence: an independent party pulled this straight from the administrator, and nobody could touch it.
  • +
  • Lead with “blockchain” in front of an allocator. In that room it is a reason to stop listening, not a moat. The Merkle root is how, not what.
  • +
+
+
+ + +
+
02

Our ICP

+

Allocators create the demand but do not pay first: long cycles, no deadline, no real budget for sourcing managers. The order below matters more than the list.

+ +
+
+
01
+
+
The payer
+

A manager running $12–300M who is raising right now

+

Twelve to thirty-six months of live track, a real administrator, and a tearsheet in Excel that gets discounted the moment an allocator opens it. Every DDQ eats six weeks. They have a deadline and a budget line that already says fundraising. That’s the whole ICP in one word: deadline.

+ $950–2,950 / mo +
+
+
+
02
+
+
The demand-maker
+

Emerging-manager programs, seeders, funds of funds, family offices, external allocation desks

+

Maybe forty of them worldwide matter. Give them the product almost free for eighteen months. We aren’t buying revenue from them. We’re buying one sentence on their submission page: we accept PodShop Arena records. Charge in year two, once the pipeline already runs through us.

+ Free → $35–90k / yr from Y2 +
+
+
+
03
+
+
The multiplier
+

Fund administrators

+

SS&C, Citco, Apex, Alter Domus, IQ-EQ. They hold the data already, and all of them are climbing up-stack into GP services. White-label us across their emerging-manager book and one signature is worth two hundred managers. Start in year two, because it’s also the conversation where somebody buys us.

+ $120–350k / yr + rev share +
+
+
+ +
+
+
Why not employers
+

Employers vetting PM candidates. It looks like the sharpest pain in the industry, and it is. Nobody can check whether a candidate really ran $500M at 1.8 Sharpe. But that P&L belongs to the old employer, who will never hand it over. Nothing to connect to.

+
+
+
Crypto funds
+

Crypto funds. Read-only exchange keys take ten minutes. No prime broker, no SFTP. And nobody distrusts a track record more than a crypto allocator. Paying customers within a month. Small cheques and a worse story for a buyer, so take it for proof, not as the plan.

+
+
+
+ +
+
PodShop Arena · Nobody wants to be verified01 / 02
+
+ + +
+ +
+ +
+
+
03

What we sell

+ + + + + + + + +
LinePriceNote
Manager subscription$0.95–2.95k/moBy connections and records
Per-raise data room$12–35kComes out of the raise, not IT
Allocator seats$35–90k/yrYear 2. Free before that
Administrator deal$120–350k/yrYear 3. The step-change
+ +

A $50M exit needs roughly $7–9.5M of ARR growing 60%+, at six to eight times revenue. Get two strategic buyers in the room and $5–6M does it.[2]

+ +
+
Y1
0.35
30 mgrs
+
Y2
1.4
110
+
Y3
4.0
260
+
Y4
8.0
480 · sell here
+
Y5
13
700
+
+

ARR in $ millions

+ +
+
How much to raise
+

Cap the raise at $6–7M. Pre-seed $1.2M, seed $4.8M, then stop.

+

A $18M round makes a $50M exit unacceptable to the board. The size of the round sets the floor on an acceptable outcome before we have sold anything. The alternative is a small round, most of the company retained, and a strategic buyer. On that path we probably could not raise from a growth fund.

+
+ +
Who buys us
+

Fund administrators first. They want the data layer and the GP relationship. Then allocator tech: ION, Nasdaq, With Intelligence, Canoe, Allvue. Then fundraising plumbing: Carta, Juniper Square, Passthrough.

+

The multiple depends on how many buyers are in the room, not on how good the product is. Two bidders is 10×. One is 5×.

+
+
+ + +
+
+
04

Order of implementation

+

Ordered by what blocks us soonest.

+
    +
  1. A written regulatory opinion in all three jurisdictions. We need counsel in the UK, EU and US to state on paper that we are a technology provider, not an adviser and not a broker-dealer. The entity, the contracts, and every claim the product is allowed to make hang off that one classification. Nothing else can be committed until it is written down. $50–85k · 8–12 wks · this week
  2. +
  3. Two or three fund administrators. Plaid has no place here — it is US retail banking and reaches none of the people we sell to. Administrators hold the data and their client list is our ICP. Everything else is IBKR Flex and SFTP.
  4. +
  5. SOC 2 Type II and ISO 27001. Nine months, so it starts in month one. No allocator lets us near a manager’s data without them.
  6. +
  7. An auditor’s report on the maths. An ISAE 3000 engagement on the calculation engine. It buys more trust than every proof mechanism in the codebase put together. $30–60k
  8. +
  9. Three allocators willing to say so in writing. Without them there is no demand to sell against.
  10. +
  11. An answer to “will my positions leak?” Not ZK. That’s year two. Year one it’s architecture: positions never touch a path a viewer can reach, metrics only by default, embargo on. They have to feel it in the demo, not read it in a PDF.
  12. +
  13. Insurance. We publish claims about other people’s numbers. One of them will be wrong eventually.
  14. +
+
+ +
+
05

Where we sell

+
Not EU or US. London or New York.
+

London. English, the second-largest concentration of hedge funds, a workable regulator, a dense community of seeders and emerging managers, and meetings we can reach without a visa. Continental Europe on its own is too fragmented and too cheap to build $8M of ARR on.

+

But build for the SEC Marketing Rule from day one anyway. It’s the strictest regime, retrofitting costs more, and our buyer is American. A company with 60–70% US revenue is simply worth more to them.

+
+
Order of operations
+

EU parent. London, months 0–12. Delaware entity and one American salesperson at month 12. Most new ARR from the US by year three.

+
+
+
+
+ + +
+
06

What we build, in order

+

The spec treats nearly everything as MVP. That’s a three-year build, and we have about nine months of runway at a time.

+
+
+
Build now
+

Administrator ingestion → immutable snapshot → deterministic metrics → badge → the fence, with its NDA gate, watermark and audit log → the disclaimer engine. That’s the product.

+
+
+
Build next
+

ZK proofs. GIPS composites. Attribution. The attestor role. None of them closes the first fifty customers, so none of them comes first.

+
+
+
Build after that
+

Retail broker connectors: Robinhood, Coinbase, Schwab. Discovery marketplace. Reputation graph. Peer cohorts. Hiring workflow. No money in them today, and no allocator has ever asked.

+
+
+
+ +
+ + +
+

[1] The analogy limps in one place: SOC 2 became near-mandatory on its own, and nothing forces an allocator to demand anything. Which is why the three signatures in §04 matter more than the product.

+

[2] Every number here is a model, and every model is wrong. The useful question is which direction, and we won’t know until we try charging someone.

+
+ +
PodShop Arena · Nobody wants to be verified02 / 02
+
+ +
+ + diff --git a/docs/strategy.md b/docs/strategy.md new file mode 100644 index 0000000..021b9d0 --- /dev/null +++ b/docs/strategy.md @@ -0,0 +1,196 @@ +# Nobody wants to be verified + +**PodShop Arena · Strategy memo · 8 August 2026 · Internal, draft** + +Companion files: the print version of this memo is [`strategy.html`](./strategy.html); +the deck we show is [`deck/index.html`](./deck/index.html). + +--- + +## 01 · Demand + +Portfolio managers want money. Allocators want to spend less time on diligence. +Verification is the toll booth between them, and a toll booth is an excellent thing to +own. + +SOC 2 is the same shape. Nobody wants the report. It costs six figures, improves nothing +a customer can see, and companies buy it because their buyers stopped signing without +one. That's what Vanta built on.[^1] + +> **The allocator creates the demand.** +> **The manager pays the bill.** + +We are not going to persuade managers that verification is good for them. The job is to +get a small number of allocators people take seriously to say four words: *send it +through PodShop Arena*. Then we sell everyone in their pipeline the ability to comply. + +### Blockchain + +- **Do** keep the anchoring and the ZK proofs. They are cheap and they work. +- **Do** sell one sentence: *an independent party pulled this straight from the + administrator, and nobody could touch it.* +- **Don't** lead with "blockchain" in front of an allocator. In that room it is a reason + to stop listening, not a moat. The Merkle root is *how*, not *what*. + +--- + +## 02 · Our ICP + +Allocators create the demand but do not pay first: long cycles, no deadline, no real +budget for sourcing managers. The order below matters more than the list. + +### 01 · The payer — a manager running $12–300M who is raising right now + +Twelve to thirty-six months of live track, a real administrator, and a tearsheet in +Excel that gets discounted the moment an allocator opens it. Every DDQ eats six weeks. +They have a deadline and a budget line that already says *fundraising*. That's the whole +ICP in one word: **deadline**. + +**$950–2,950 / mo** + +### 02 · The demand-maker — emerging-manager programs, seeders, funds of funds, family offices, external allocation desks + +Maybe forty of them worldwide matter. Give them the product almost free for eighteen +months. We aren't buying revenue from them. We're buying one sentence on their +submission page: *we accept PodShop Arena records.* Charge in year two, once the pipeline +already runs through us. + +**Free → $35–90k / yr from Y2** + +### 03 · The multiplier — fund administrators + +SS&C, Citco, Apex, Alter Domus, IQ-EQ. They hold the data already, and all of them are +climbing up-stack into GP services. White-label us across their emerging-manager book +and one signature is worth two hundred managers. Start in year two, because it's also +the conversation where somebody buys us. + +**$120–350k / yr + rev share** + +### Why not employers + +**Employers vetting PM candidates.** It looks like the sharpest pain in the industry, and +it is. Nobody can check whether a candidate really ran $500M at 1.8 Sharpe. But that P&L +belongs to the old employer, who will never hand it over. Nothing to connect to. + +### Crypto funds + +Read-only exchange keys take ten minutes. No prime broker, no SFTP. And nobody distrusts +a track record more than a crypto allocator. Paying customers within a month. Small +cheques and a worse story for a buyer, so take it for proof, not as the plan. + +--- + +## 03 · What we sell + +| Line | Price | Note | +| --- | --- | --- | +| Manager subscription | $0.95–2.95k / mo | By connections and records | +| Per-raise data room | $12–35k | Comes out of the raise, not IT | +| Allocator seats | $35–90k / yr | Year 2. Free before that | +| Administrator deal | $120–350k / yr | Year 3. The step-change | + +A $50M exit needs roughly **$7–9.5M of ARR** growing 60%+, at six to eight times +revenue. Get two strategic buyers in the room and $5–6M does it.[^2] + +### The ramp (ARR, $M) + +| | Y1 | Y2 | Y3 | **Y4** | Y5 | +| --- | --- | --- | --- | --- | --- | +| ARR | 0.35 | 1.4 | 4.0 | **8.0** | 13 | +| Managers | 30 | 110 | 260 | **480 · sell here** | 700 | + +### How much to raise + +**Cap the raise at $6–7M.** Pre-seed $1.2M, seed $4.8M, then stop. + +A $18M round makes a $50M exit unacceptable to the board. The size of the round sets the +floor on an acceptable outcome before we have sold anything. The alternative is a small +round, most of the company retained, and a strategic buyer. On that path we probably +could not raise from a growth fund. + +### Who buys us + +Fund administrators first. They want the data layer and the GP relationship. Then +allocator tech: ION, Nasdaq, With Intelligence, Canoe, Allvue. Then fundraising +plumbing: Carta, Juniper Square, Passthrough. + +The multiple depends on how many buyers are in the room, not on how good the product is. +Two bidders is 10×. One is 5×. + +--- + +## 04 · Order of implementation + +Ordered by what blocks us soonest. + +1. **A written regulatory opinion in all three jurisdictions.** We need counsel in the + UK, EU and US to state on paper that we are a technology provider, not an adviser and + not a broker-dealer. The entity, the contracts, and every claim the product is + allowed to make hang off that one classification. Nothing else can be committed until + it is written down. — *$50–85k · 8–12 wks · this week* +2. **Two or three fund administrators.** Plaid has no place here — it is US retail + banking and reaches none of the people we sell to. Administrators hold the data *and* + their client list is our ICP. Everything else is IBKR Flex and SFTP. +3. **SOC 2 Type II and ISO 27001.** Nine months, so it starts in month one. No allocator + lets us near a manager's data without them. +4. **An auditor's report on the maths.** An ISAE 3000 engagement on the calculation + engine. It buys more trust than every proof mechanism in the codebase put together. — + *$30–60k* +5. **Three allocators willing to say so in writing.** Without them there is no demand to + sell against. +6. **An answer to "will my positions leak?"** Not ZK. That's year two. Year one it's + architecture: positions never touch a path a viewer can reach, metrics only by + default, embargo on. They have to feel it in the demo, not read it in a PDF. +7. **Insurance.** We publish claims about other people's numbers. One of them will be + wrong eventually. + +--- + +## 05 · Where we sell + +> **Not EU or US. London or New York.** + +London. English, the second-largest concentration of hedge funds, a workable regulator, a +dense community of seeders and emerging managers, and meetings we can reach without a +visa. Continental Europe on its own is too fragmented and too cheap to build $8M of ARR +on. + +But build for the SEC Marketing Rule from day one anyway. It's the strictest regime, +retrofitting costs more, and our buyer is American. A company with 60–70% US revenue is +simply worth more to them. + +**Order of operations.** EU parent. London, months 0–12. Delaware entity and one American +salesperson at month 12. Most new ARR from the US by year three. + +--- + +## 06 · What we build, in order + +The spec treats nearly everything as MVP. That's a three-year build, and we have about +nine months of runway at a time. + +### Build now + +Administrator ingestion → immutable snapshot → deterministic metrics → badge → the fence, +with its NDA gate, watermark and audit log → the disclaimer engine. **That's the +product.** + +### Build next + +ZK proofs. GIPS composites. Attribution. The attestor role. None of them closes the first +fifty customers, so none of them comes first. + +### Build after that + +Retail broker connectors: Robinhood, Coinbase, Schwab. Discovery marketplace. Reputation +graph. Peer cohorts. Hiring workflow. No money in them today, and no allocator has ever +asked. + +--- + +[^1]: The analogy limps in one place: SOC 2 became near-mandatory on its own, and nothing +forces an allocator to demand anything. Which is why the three signatures in §04 matter +more than the product. + +[^2]: Every number here is a model, and every model is wrong. The useful question is +which direction, and we won't know until we try charging someone. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cecb3f1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "podarena" +version = "0.1.0" +description = "Track records an allocator can verify without trusting us" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.32", + "pydantic>=2.9", + "pydantic-settings>=2.6", + "sqlalchemy>=2.0.36", + "alembic>=1.14", + "psycopg[binary]>=3.2", + "python-multipart>=0.0.17", + "dkimpy>=1.1.8", + "dnspython>=2.7", + "web3>=7.6", + "boto3>=1.35", + "cryptography>=50.0.0", +] + +[project.scripts] +podarena-verify = "verifier.cli:main" + +[dependency-groups] +dev = [ + "pytest>=8.3", + "httpx>=0.28", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["core", "adapters", "api", "worker", "verifier", "demo"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +filterwarnings = ["ignore::DeprecationWarning"] diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..3546d5c --- /dev/null +++ b/render.yaml @@ -0,0 +1,86 @@ +# Render blueprint. One web service, one Postgres, one disk for evidence blobs. +# +# Deploy: push this repo, then New → Blueprint on Render and point it here. +# Everything below except the two chain secrets is set automatically. + +services: + - type: web + name: podarena + runtime: docker + dockerfilePath: ./Dockerfile + plan: starter # a disk needs a paid instance; see PODARENA_S3_BUCKET below + healthCheckPath: /api/health + autoDeploy: true + + # The branch we actually develop on. Without this Render deploys the repo's + # default branch, which is how a fix can sit committed and reviewed and + # still not be the code that is running. + branch: feat/manager-console + + disk: + # Raw documents are write-once and content-addressed. They must survive a + # redeploy or the DKIM proofs go with them. + name: evidence + mountPath: /data + sizeGB: 1 + + envVars: + - key: PODARENA_DATABASE_URL + fromDatabase: + name: podarena-db + property: connectionString + + - key: PODARENA_PUBLIC_BASE_URL + fromService: + type: web + name: podarena + property: host + + # Runs the synthetic fixture corpus and stages the demo records. + # Turn both off before this instance ever holds a real track record: + # demo mode adds a fake broker domain to the DKIM allow-list. + - key: PODARENA_DEMO_FIXTURES + value: "true" + - key: PODARENA_SEED_DEMO + value: "true" + + # Monad Testnet, per docs.monad.xyz/developer-essentials. + - key: PODARENA_CHAIN_ID + value: "10143" + - key: PODARENA_RPC_URL + value: https://testnet-rpc.monad.xyz + - key: PODARENA_EXPLORER_URL + value: https://testnet.monadvision.com + + # Set these two in the dashboard after deploying the contract. + # Without them the app still computes and stores roots; it just marks + # them unanchored rather than pretending otherwise. + - key: PODARENA_REGISTRY_ADDRESS + sync: false + - key: PODARENA_ANCHOR_PRIVATE_KEY + sync: false + + # Guards the ingest and invite routes, and opens the manager console. + # Required once demo mode is off. While PODARENA_DEMO_FIXTURES is true + # this value is served by /api/config so the console's login screen can + # fill it in — deliberate, and safe only because a fixtures instance holds + # nothing but the synthetic corpus. Turning demo mode off stops that + # immediately; rotate this at the same time. + - key: PODARENA_ADMIN_TOKEN + generateValue: true + + # Prefer object storage to a disk? Set these and drop the disk block. + # - key: PODARENA_S3_BUCKET + # sync: false + # - key: PODARENA_S3_ENDPOINT_URL + # sync: false + # - key: PODARENA_AWS_ACCESS_KEY_ID + # sync: false + # - key: PODARENA_AWS_SECRET_ACCESS_KEY + # sync: false + +databases: + - name: podarena-db + plan: free + databaseName: podarena + user: podarena diff --git a/screenshots/record-view.jpg b/screenshots/record-view.jpg new file mode 100644 index 0000000..b7ee020 Binary files /dev/null and b/screenshots/record-view.jpg differ diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100644 index 0000000..88a2b25 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Boot: schema, then optionally the staged demo, then serve. +set -e + +echo "→ applying schema" +python -m adapters.bootstrap + +if [ "${PODARENA_SEED_DEMO}" = "true" ]; then + echo "→ seeding the demo records" + # Idempotent: existing records are left alone, so a redeploy does not wipe + # anything and does not stack duplicate snapshots. + python -m demo.seed || echo " seeding failed; the app will still start" +fi + +echo "→ serving on ${PORT:-8000}" +exec uvicorn api.app:app --host 0.0.0.0 --port "${PORT:-8000}" --forwarded-allow-ips '*' diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e6f73d0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,74 @@ +"""Test fixtures. + +The environment has to be set before anything imports `adapters.config`, +because settings are cached for the life of the process — so this runs first, +at import time, on purpose. +""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +_TMP = Path(tempfile.mkdtemp(prefix="podarena-tests-")) + +os.environ["PODARENA_DATABASE_URL"] = f"sqlite:///{_TMP / 'test.db'}" +os.environ["PODARENA_BLOB_DIR"] = str(_TMP / "blobs") +os.environ["PODARENA_DEMO_FIXTURES"] = "true" +os.environ["PODARENA_REGISTRY_ADDRESS"] = "" +os.environ["PODARENA_ANCHOR_PRIVATE_KEY"] = "" + +import pytest # noqa: E402 + +from adapters import db # noqa: E402 +from demo.corpus import Corpus, build_corpus, demo_institutions # noqa: E402 +from worker import pipeline # noqa: E402 + + +@pytest.fixture(scope="session") +def corpus() -> Corpus: + """One signed corpus for the whole session. + + Generating three 2048-bit RSA keys per test would dominate the runtime, and + the corpus is immutable, so a session fixture is both faster and honest. + """ + built = build_corpus() + pipeline.set_fixture_dns(built.captured_dns) + return built + + +@pytest.fixture(scope="session") +def institutions(): + return demo_institutions() + + +@pytest.fixture() +def session(corpus): + db.Base.metadata.drop_all(db.engine()) + db.create_all() + db_session = db.SessionLocal() + try: + yield db_session + finally: + db_session.rollback() + db_session.close() + + +@pytest.fixture() +def record(session): + from adapters.chain import record_key + + entity = db.Entity(name="Test Fund") + session.add(entity) + session.flush() + row = db.Record( + entity_id=entity.id, + slug="test-fund", + name="Test Fund", + chain_key=record_key("test-fund"), + viewer_salt="test-salt", + ) + session.add(row) + session.flush() + return row diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..552cfa2 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,136 @@ +"""Properties the rest of the system is built on. + +If any of these break, the anchor stops meaning anything — so they are worth +testing directly rather than only through the pipeline. +""" + +from __future__ import annotations + +from datetime import date, datetime, timezone +from decimal import Decimal + +import pytest + +from core.canonical import NotCanonical, canonical, canonical_decimal, leaf_hash +from core.merkle import EmptyTree, MerkleTree, verify_proof +from core.money import CurrencyMismatch, Money +from core.tiers import NoEvidence, Tier, min_tier + + +class TestCanonical: + def test_equal_decimals_serialize_identically(self): + # Decimal("1.1") and Decimal("1.10") are the same number. If they + # hashed differently, two machines could disagree about a metric they + # both computed correctly. + assert canonical_decimal(Decimal("1.1")) == canonical_decimal(Decimal("1.10")) + assert canonical(Decimal("2.50")) == canonical(Decimal("2.5")) + + def test_negative_zero_is_zero(self): + assert canonical_decimal(Decimal("-0.0")) == canonical_decimal(Decimal("0")) + + def test_key_order_does_not_matter(self): + assert canonical({"b": 1, "a": 2}) == canonical({"a": 2, "b": 1}) + + def test_float_is_refused(self): + with pytest.raises(NotCanonical): + canonical({"nav": 41207338.22}) + + def test_naive_datetime_is_refused(self): + # There is no correct guess for what timezone a naive datetime meant. + with pytest.raises(NotCanonical): + canonical(datetime(2026, 1, 1)) + + def test_aware_datetimes_normalize_to_utc(self): + from datetime import timedelta + + utc = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc) + offset = datetime( + 2026, 1, 1, 14, 0, tzinfo=timezone(timedelta(hours=2)) + ) + assert canonical(utc) == canonical(offset) + + def test_leaf_kinds_are_domain_separated(self): + payload = {"same": "bytes"} + assert leaf_hash("document", payload) != leaf_hash("fact", payload) + + +class TestMerkle: + def test_every_proof_verifies(self): + leaves = [leaf_hash("fact", {"i": i}) for i in range(9)] + tree = MerkleTree(leaves) + for leaf in leaves: + assert verify_proof(tree.proof_for(leaf), tree.root) + + def test_proof_fails_against_a_different_root(self): + tree = MerkleTree([leaf_hash("fact", {"i": i}) for i in range(4)]) + assert not verify_proof(tree.proof_for(tree.leaves[0]), b"\x00" * 32) + + def test_root_is_order_independent(self): + leaves = [leaf_hash("fact", {"i": i}) for i in range(6)] + assert MerkleTree(leaves).root == MerkleTree(list(reversed(leaves))).root + + def test_changing_one_leaf_changes_the_root(self): + base = [leaf_hash("fact", {"i": i}) for i in range(5)] + altered = base[:-1] + [leaf_hash("fact", {"i": 999})] + assert MerkleTree(base).root != MerkleTree(altered).root + + def test_empty_tree_is_refused(self): + # An empty root is a commitment to nothing that still looks like one. + with pytest.raises(EmptyTree): + MerkleTree([]) + + def test_odd_leaf_is_promoted_not_duplicated(self): + # Duplicating the last leaf is the classic construction and the classic + # bug: it lets two different leaf sets fold to the same root. + three = [leaf_hash("fact", {"i": i}) for i in range(3)] + four = three + [three[-1]] + assert MerkleTree(three).root != MerkleTree(four).root + + +class TestMoney: + def test_minor_units_are_exact(self): + assert Money.from_decimal(Decimal("41207338.22"), "USD").minor == 4120733822 + + def test_float_is_refused(self): + with pytest.raises(TypeError): + Money.from_decimal(1.5, "USD") # type: ignore[arg-type] + + def test_sub_minor_precision_is_refused_rather_than_rounded(self): + with pytest.raises(ValueError): + Money.from_decimal(Decimal("1.005"), "USD") + + def test_currency_mismatch_raises(self): + with pytest.raises(CurrencyMismatch): + Money(100, "USD") + Money(100, "EUR") + + def test_zero_decimal_currency(self): + assert Money.from_decimal(Decimal("1200"), "JPY").minor == 1200 + + +class TestTiers: + def test_ordering_runs_weakest_to_strongest(self): + assert Tier.SELF_REPORTED < Tier.AGGREGATOR_ATTESTED < Tier.SOURCE_SIGNED + + def test_one_weak_input_caps_the_whole_metric(self): + eleven_signed = [Tier.SOURCE_SIGNED] * 11 + assert min_tier(eleven_signed + [Tier.SELF_REPORTED]) is Tier.SELF_REPORTED + + def test_empty_input_is_fatal(self): + # Returning SOURCE_SIGNED would invent trust; returning SELF_REPORTED + # would hide that a metric was computed from nothing. + with pytest.raises(NoEvidence): + min_tier([]) + + +class TestTierAssignment: + def test_unverified_mail_falls_all_the_way_to_self_reported(self): + from core.types import Channel, tier_for + + # Not aggregator_attested — nobody attested to anything. + assert tier_for(Channel.EML_UPLOAD, False) is Tier.SELF_REPORTED + assert tier_for(Channel.EML_UPLOAD, True) is Tier.SOURCE_SIGNED + + def test_csv_can_never_be_signed(self): + from core.types import Channel, tier_for + + assert tier_for(Channel.CSV_UPLOAD, True) is Tier.SELF_REPORTED diff --git a/tests/test_determinism.py b/tests/test_determinism.py new file mode 100644 index 0000000..c9adb1b --- /dev/null +++ b/tests/test_determinism.py @@ -0,0 +1,204 @@ +"""Same inputs, same bytes, same root. + +This is the contract that makes everything else meaningful, so it is the test +that must never be skipped. If it fails, the anchor is committing to something +other than what we think it is. + +The golden root below is a hard-coded constant on purpose. Building the same +snapshot twice in one process only proves the code is not obviously random; a +constant checked into the repo proves that a different machine, a different +Python build, and a different day all agree. +""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import date, datetime, timezone + +from core import METHODOLOGY_VERSION +from core.canonical import canonical, sha256_hex +from core.metrics import Config, FeeModel, build_series, compute +from core.snapshot import build_snapshot +from core.tiers import Tier +from core.types import Channel, DkimVerdict, DocType, Document, Fact, FactKind + +# A fixed scenario with no generated keys in it, so the expected root is stable +# across machines and runs. +FIXED_NAVS = [ + (date(2026, 1, 31), 100_000_000), + (date(2026, 2, 28), 104_500_000), + (date(2026, 3, 31), 99_750_000), + (date(2026, 4, 30), 108_200_000), +] + +GOLDEN_ROOT = "0x0b3b27efe1a6dea1d9d32eed279d375434038bda80d491ebb33bbf991d0838e8" + + +def fixed_facts() -> list[Fact]: + facts = [ + Fact( + account_id="account-1", + document_sha256=f"{index:064x}", + as_of=day, + kind=FactKind.NAV, + tier=Tier.SOURCE_SIGNED, + currency="USD", + amount_minor=minor, + extractor_version="1.0.0", + ) + for index, (day, minor) in enumerate(FIXED_NAVS) + ] + facts.append( + Fact( + account_id="account-1", + document_sha256=f"{99:064x}", + as_of=date(2026, 3, 10), + kind=FactKind.CASH_FLOW, + tier=Tier.SOURCE_SIGNED, + currency="USD", + amount_minor=5_000_000, + extractor_version="1.0.0", + ) + ) + return facts + + +def fixed_documents() -> list[Document]: + return [ + Document( + sha256=f"{index:064x}", + blob_key=f"blob-{index}", + byte_length=1024 + index, + received_at=datetime(2026, 5, 1, 9, 0, tzinfo=timezone.utc), + channel=Channel.EML_UPLOAD, + institution_id="ibkr", + doc_type=DocType.STATEMENT, + ) + for index in range(len(FIXED_NAVS)) + ] + + +def fixed_verdicts() -> list[tuple[str, DkimVerdict]]: + return [ + ( + f"{index:064x}", + DkimVerdict( + verified=True, + d_domain="ibkr-demo.podarena.test", + selector="blitz2026", + algo="rsa-sha256", + l_tag_present=False, + dns_txt_record="v=DKIM1; k=rsa; p=FIXEDKEYFORTESTS", + dns_captured_at=datetime(2026, 5, 1, 9, 0, tzinfo=timezone.utc), + body_hash_matched=True, + signed_headers=("from", "to", "subject", "date"), + ), + ) + for index in range(len(FIXED_NAVS)) + ] + + +def build_once(): + facts = fixed_facts() + series = build_series(facts) + metrics = compute(series, Config(fee_model=FeeModel())) + return build_snapshot( + record_id="determinism-fixture", + seq=1, + documents=fixed_documents(), + verdicts=fixed_verdicts(), + facts=facts, + metrics=metrics, + methodology_version=METHODOLOGY_VERSION, + extractor_versions={"ibkr.activity_statement": "1.0.0"}, + ) + + +class TestDeterminism: + def test_root_matches_the_committed_constant(self): + """The cross-machine check. A change here is either a bug or a + deliberate methodology change that must bump a version.""" + assert build_once().root_hex == GOLDEN_ROOT + + def test_two_builds_agree(self): + assert build_once().root_hex == build_once().root_hex + + def test_metric_values_are_byte_identical(self): + first = {(m.key, m.basis): m.value for m in build_once_metrics()} + second = {(m.key, m.basis): m.value for m in build_once_metrics()} + for key in first: + assert canonical(first[key]) == canonical(second[key]) + + def test_fact_hashes_are_stable(self): + hashes = [sha256_hex(canonical(f.canonical_form())) for f in fixed_facts()] + again = [sha256_hex(canonical(f.canonical_form())) for f in fixed_facts()] + assert hashes == again + + def test_fact_order_does_not_change_the_root(self): + """Leaves are sorted, so the root depends on the set of evidence and not + on the order the pipeline happened to emit it.""" + facts = fixed_facts() + forward = _snapshot_with(facts) + backward = _snapshot_with(list(reversed(facts))) + assert forward.root_hex == backward.root_hex + + def test_changing_one_minor_unit_changes_the_root(self): + facts = fixed_facts() + facts[0] = replace(facts[0], amount_minor=100_000_001) + assert _snapshot_with(facts).root_hex != GOLDEN_ROOT + + def test_bumping_an_extractor_version_changes_the_root(self): + """The metrics leaf commits to the code that did the reading, so fixing + a parser cannot silently keep the old commitment.""" + facts = fixed_facts() + series = build_series(facts) + metrics = compute(series, Config(fee_model=FeeModel())) + bumped = build_snapshot( + record_id="determinism-fixture", + seq=1, + documents=fixed_documents(), + verdicts=fixed_verdicts(), + facts=facts, + metrics=metrics, + methodology_version=METHODOLOGY_VERSION, + extractor_versions={"ibkr.activity_statement": "1.0.1"}, + ) + assert bumped.root_hex != GOLDEN_ROOT + + def test_changing_the_captured_dns_key_changes_the_root(self): + facts = fixed_facts() + series = build_series(facts) + metrics = compute(series, Config(fee_model=FeeModel())) + verdicts = fixed_verdicts() + sha, verdict = verdicts[0] + verdicts[0] = (sha, replace(verdict, dns_txt_record="v=DKIM1; p=DIFFERENT")) + altered = build_snapshot( + record_id="determinism-fixture", + seq=1, + documents=fixed_documents(), + verdicts=verdicts, + facts=facts, + metrics=metrics, + methodology_version=METHODOLOGY_VERSION, + extractor_versions={"ibkr.activity_statement": "1.0.0"}, + ) + assert altered.root_hex != GOLDEN_ROOT + + +def build_once_metrics(): + return compute(build_series(fixed_facts()), Config(fee_model=FeeModel())) + + +def _snapshot_with(facts: list[Fact]): + series = build_series(facts) + metrics = compute(series, Config(fee_model=FeeModel())) + return build_snapshot( + record_id="determinism-fixture", + seq=1, + documents=fixed_documents(), + verdicts=fixed_verdicts(), + facts=facts, + metrics=metrics, + methodology_version=METHODOLOGY_VERSION, + extractor_versions={"ibkr.activity_statement": "1.0.0"}, + ) diff --git a/tests/test_dkim.py b/tests/test_dkim.py new file mode 100644 index 0000000..6e26646 --- /dev/null +++ b/tests/test_dkim.py @@ -0,0 +1,133 @@ +"""The DKIM fixture corpus. + +A corpus of real signed messages: valid, l=-tagged, key-rotated, body-tampered +by one byte, forwarded-and-broken. Each has an expected verdict, and the whole +point is that these run against actual RSA rather than a mock — a mocked +verifier would happily agree with whatever we told it. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from core.dkim import required_dns_names, signatures, verify_message + +NOW = datetime(2026, 8, 8, 12, 0, tzinfo=timezone.utc) + + +def verdict_for(raw, corpus, institutions, records=None): + return verify_message( + raw, + dns_records=records if records is not None else corpus.captured_dns, + institutions=institutions, + captured_at=NOW, + ) + + +def test_every_fixture_matches_its_expected_verdict(corpus, institutions): + failures = [] + for fixture in corpus.fixtures: + analysis = verdict_for(fixture.raw, corpus, institutions) + if analysis.verdict.verified != fixture.expected_verified: + failures.append( + f"{fixture.name}: expected verified={fixture.expected_verified}, " + f"got {analysis.verdict.verified} " + f"({analysis.verdict.failure_reason})" + ) + assert not failures, "\n".join(failures) + + +def test_l_tag_is_a_hard_fail(corpus, institutions): + """With l=, only the first N body bytes are signed and anything can be + appended below. That is a failure, not a warning.""" + analysis = verdict_for(corpus.by_name("l-tag-appended").raw, corpus, institutions) + assert analysis.verdict.verified is False + assert analysis.verdict.l_tag_present is True + assert "l=" in (analysis.verdict.failure_reason or "") + + +def test_one_flipped_byte_breaks_the_body_hash(corpus, institutions): + analysis = verdict_for(corpus.by_name("tampered-body").raw, corpus, institutions) + assert analysis.verdict.verified is False + assert analysis.verdict.body_hash_matched is False + assert "body hash" in (analysis.verdict.failure_reason or "").lower() + + +def test_a_forwarders_signature_buys_nothing(corpus, institutions): + """Gmail's signature on a forward proves Gmail handled it. An allocator + cannot spend that.""" + analysis = verdict_for(corpus.by_name("forwarded-broken").raw, corpus, institutions) + assert analysis.verdict.verified is False + + ignored = [s for s in analysis.summaries if not s.considered] + assert ignored, "the forwarder's signature should be listed and dismissed" + assert any("not a known institution" in (s.reason or "") for s in ignored) + + +def test_valid_signature_from_an_unknown_domain_is_not_enough(corpus, institutions): + analysis = verdict_for(corpus.by_name("unknown-domain").raw, corpus, institutions) + assert analysis.verdict.verified is False + assert "known institution" in (analysis.verdict.failure_reason or "") + + +class TestKeyRotation: + """The argument for anchoring the captured key, stated as a test. + + A signature we can verify today is unverifiable in a year once the selector + is retired — unless somebody wrote the key down at the time. + """ + + def test_retired_selector_cannot_be_verified_from_live_dns(self, corpus, institutions): + archived = corpus.by_name("archived-retired-selector") + analysis = verdict_for(archived.raw, corpus, institutions, records=corpus.live_dns) + assert analysis.verdict.verified is False + assert "retired" in (analysis.verdict.failure_reason or "") + + def test_the_captured_record_still_verifies_it(self, corpus, institutions): + archived = corpus.by_name("archived-retired-selector") + analysis = verdict_for(archived.raw, corpus, institutions) + assert analysis.verdict.verified is True + assert analysis.verdict.dns_txt_record is not None + assert analysis.verdict.dns_captured_at == NOW + + def test_the_captured_record_is_committed_by_hash(self, corpus, institutions): + analysis = verdict_for( + corpus.by_name("archived-retired-selector").raw, corpus, institutions + ) + form = analysis.verdict.canonical_form() + assert form["dns_record_hash"] == analysis.verdict.dns_record_hash + # The key itself is not in the leaf; its hash is. The record lives in + # the database and travels in the export bundle. + assert "dns_txt_record" not in form + + +def test_verification_never_touches_the_network(corpus, institutions, monkeypatch): + """`core` is pure. If a DNS lookup ever crept into the verifier, this test + would be the thing that noticed.""" + import socket + + def explode(*args, **kwargs): + raise AssertionError("core.dkim attempted a network call") + + monkeypatch.setattr(socket, "socket", explode) + monkeypatch.setattr(socket, "getaddrinfo", explode) + + analysis = verdict_for(corpus.by_name("statement-2026-07").raw, corpus, institutions) + assert analysis.verdict.verified is True + + +def test_parser_finds_every_signature_and_its_lookup_name(corpus): + raw = corpus.by_name("forwarded-broken").raw + found = signatures(raw) + assert len(found) == 2 + names = required_dns_names(raw) + assert len(names) == 2 + assert all("._domainkey." in name for name in names) + + +@pytest.mark.parametrize("name", ["statement-2025-08", "statement-2026-07", "trade-confirmation"]) +def test_signed_headers_cover_from(corpus, institutions, name): + analysis = verdict_for(corpus.by_name(name).raw, corpus, institutions) + assert "from" in analysis.verdict.signed_headers diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..69ee511 --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,200 @@ +"""The maths, checked against numbers worked out by hand. + +An auditor's report on the calculation engine buys more trust than every proof +mechanism in the codebase put together, and this file is the part of that we can +write ourselves. +""" + +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +import pytest + +from core.metrics import ( + Config, + FeeModel, + InsufficientSeries, + TWO_AND_TWENTY, + build_series, + compute, + max_drawdown, + period_return, +) +from core.tiers import Tier +from core.types import Fact, FactKind + + +def nav(day: date, minor: int, tier: Tier = Tier.SOURCE_SIGNED) -> Fact: + return Fact( + account_id="a", + document_sha256=f"doc-{day}", + as_of=day, + kind=FactKind.NAV, + tier=tier, + currency="USD", + amount_minor=minor, + ) + + +def flow(day: date, minor: int, tier: Tier = Tier.SOURCE_SIGNED) -> Fact: + return Fact( + account_id="a", + document_sha256=f"flow-{day}", + as_of=day, + kind=FactKind.CASH_FLOW, + tier=tier, + currency="USD", + amount_minor=minor, + ) + + +class TestModifiedDietz: + def test_simple_growth(self): + series = build_series([nav(date(2026, 1, 31), 100_000_000), + nav(date(2026, 2, 28), 110_000_000)]) + assert period_return(series.periods[0]) == Decimal("0.1") + + def test_flow_is_weighted_by_time_invested(self): + """Two million arriving on the last day of the month did not have a + month to work, and a naive return pretends it did.""" + series = build_series( + [ + nav(date(2026, 1, 1), 100_000_000), + nav(date(2026, 1, 31), 160_000_000), + flow(date(2026, 1, 16), 50_000_000), # day 15 of 30 + ] + ) + # R = (160m - 100m - 50m) / (100m + (15/30)(50m)) = 10m / 125m = 0.08 + assert period_return(series.periods[0]) == Decimal("0.08") + + def test_a_flow_the_day_after_the_opening_nav_gets_nearly_full_weight(self): + series = build_series( + [ + nav(date(2026, 1, 1), 100_000_000), + nav(date(2026, 1, 31), 210_000_000), + flow(date(2026, 1, 2), 100_000_000), + ] + ) + # weight = (30 - 1) / 30 + expected = (Decimal(210_000_000) - 100_000_000 - 100_000_000) / ( + Decimal(100_000_000) + (Decimal(29) / 30) * 100_000_000 + ) + assert abs(period_return(series.periods[0]) - expected) < Decimal("1E-20") + + def test_a_flow_on_the_opening_nav_date_is_already_inside_that_nav(self): + """NAV is struck at the end of the day, so money that arrived on the + same date is already counted in the opening balance. Including it again + would double-count the contribution and understate the return.""" + series = build_series( + [ + nav(date(2026, 1, 1), 100_000_000), + nav(date(2026, 1, 31), 110_000_000), + flow(date(2026, 1, 1), 100_000_000), + ] + ) + assert series.periods[0].flows == () + assert period_return(series.periods[0]) == Decimal("0.1") + + +class TestDrawdown: + def test_a_withdrawal_is_not_a_loss(self): + """The judgement call worth arguing about. NAV fell 20%; the manager + lost nothing. Drawdown is measured on the return index for exactly this + reason.""" + facts = [ + nav(date(2026, 1, 31), 100_000_000), + nav(date(2026, 2, 28), 80_000_000), + nav(date(2026, 3, 31), 80_000_000), + flow(date(2026, 2, 1), -20_000_000), + ] + series = build_series(facts) + results = {(m.key, m.basis): m.value for m in compute(series)} + assert results[("max_drawdown", "net")] == Decimal("0E-10") + + def test_a_real_loss_is_a_drawdown(self): + facts = [ + nav(date(2026, 1, 31), 100_000_000), + nav(date(2026, 2, 28), 80_000_000), + nav(date(2026, 3, 31), 90_000_000), + ] + series = build_series(facts) + results = {(m.key, m.basis): m.value for m in compute(series)} + assert results[("max_drawdown", "net")] == Decimal("-0.2") + + def test_index_drawdown_directly(self): + assert max_drawdown([Decimal("0.1"), Decimal("-0.5"), Decimal("0.2")]) == Decimal( + "-0.5" + ) + + +class TestTierPropagation: + def test_one_self_reported_nav_caps_every_metric(self): + facts = [ + nav(date(2026, 1, 31), 100_000_000), + nav(date(2026, 2, 28), 110_000_000), + nav(date(2026, 3, 31), 120_000_000, tier=Tier.SELF_REPORTED), + ] + series = build_series(facts) + assert series.tier is Tier.SELF_REPORTED + assert all(m.tier is Tier.SELF_REPORTED for m in compute(series)) + + +class TestNetAndGross: + def test_gross_exceeds_net_once_fees_are_modelled(self): + facts = [ + nav(date(2026, 1, 31), 1_000_000_000), + nav(date(2026, 2, 28), 1_100_000_000), + nav(date(2026, 3, 31), 1_200_000_000), + ] + series = build_series(facts) + results = {(m.key, m.basis): m.value for m in compute(series, Config(fee_model=TWO_AND_TWENTY))} + assert results[("twr_cumulative", "gross")] > results[("twr_cumulative", "net")] + + def test_with_no_fee_terms_gross_equals_net(self): + """Commissions on a fund of any size are rounding error. Pretending + otherwise would be theatre.""" + facts = [ + nav(date(2026, 1, 31), 1_000_000_000), + nav(date(2026, 2, 28), 1_100_000_000), + nav(date(2026, 3, 31), 1_200_000_000), + ] + series = build_series(facts) + results = {(m.key, m.basis): m.value for m in compute(series, Config(fee_model=FeeModel()))} + assert results[("twr_cumulative", "gross")] == results[("twr_cumulative", "net")] + + def test_the_fee_terms_travel_with_the_gross_number(self): + facts = [nav(date(2026, 1, 31), 1_000_000_000), nav(date(2026, 2, 28), 1_100_000_000)] + series = build_series(facts) + gross = [m for m in compute(series, Config(fee_model=TWO_AND_TWENTY)) if m.basis == "gross"] + assert all("fee_model" in m.inputs for m in gross) + assert gross[0].inputs["fee_model"]["management_bps_per_annum"] == Decimal(200) + + +class TestRefusals: + def test_one_observation_is_a_balance_not_a_record(self): + with pytest.raises(InsufficientSeries): + build_series([nav(date(2026, 1, 31), 100_000_000)]) + + def test_mixed_currencies_are_not_silently_converted(self): + """Converting would mean inventing an FX rate nobody signed.""" + euro = Fact( + account_id="a", + document_sha256="d", + as_of=date(2026, 2, 28), + kind=FactKind.NAV, + tier=Tier.SOURCE_SIGNED, + currency="EUR", + amount_minor=110_000_000, + ) + with pytest.raises(InsufficientSeries): + build_series([nav(date(2026, 1, 31), 100_000_000), euro]) + + +class TestCadence: + def test_monthly_data_annualizes_by_twelve(self): + facts = [nav(date(2026, m, 28), 100_000_000 + m) for m in range(1, 8)] + series = build_series(facts) + volatility = [m for m in compute(series) if m.key == "volatility_annualized"][0] + assert volatility.inputs["periods_per_year"] == Decimal(12) diff --git a/tests/test_owner_console.py b/tests/test_owner_console.py new file mode 100644 index 0000000..1f7c8f7 --- /dev/null +++ b/tests/test_owner_console.py @@ -0,0 +1,217 @@ +"""The manager's side, and the credential that opens it. + +Two things are worth pinning down here. First, that the demo token is only ever +handed to a browser on an instance running the synthetic corpus — that endpoint +is public, so a mistake there is a published password. Second, that the owner +view goes *through* the projection rather than around it: if the manager's own +console reached into the ORM directly it would drift from what allocators get, +and the drift would be invisible until an allocator noticed. +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from adapters.config import settings +from api.app import app, pm_token +from api.projection import Profile + +from tests.test_projection import seeded # noqa: F401 (fixture) + + +@pytest.fixture() +def client(): + return TestClient(app) + + +@pytest.fixture() +def admin(monkeypatch): + """An instance with a token set, so the console has a real door.""" + cfg = settings() + monkeypatch.setattr(cfg, "admin_token", "test-admin-token", raising=False) + return "test-admin-token" + + +def auth(token: str) -> dict[str, str]: + return {"authorization": f"Bearer {token}"} + + +class TestDemoCredential: + def test_offered_only_on_a_fixtures_instance(self, client, admin, monkeypatch): + assert client.get("/api/config").json()["demo_admin_token"] == admin + + monkeypatch.setattr(settings(), "demo_fixtures", False, raising=False) + assert client.get("/api/config").json()["demo_admin_token"] is None + + def test_absent_when_no_token_is_configured(self, client, monkeypatch): + monkeypatch.setattr(settings(), "admin_token", None, raising=False) + assert client.get("/api/config").json()["demo_admin_token"] is None + + +class TestOwnerEndpointsAreGuarded: + @pytest.mark.parametrize( + "path", + [ + "/api/records/fence-fund/owner", + "/api/records/fence-fund/disclosure", + "/api/records/fence-fund/preview/summary", + "/api/records/fence-fund/invites", + "/api/records/fence-fund/access-log", + ], + ) + def test_refused_without_the_token(self, client, seeded, admin, path): + assert client.get(path).status_code == 401 + assert client.get(path, headers=auth("wrong")).status_code == 401 + assert client.get(path, headers=auth(admin)).status_code == 200 + + +class TestDisclosureMatrix: + def test_every_rung_is_measured(self, client, seeded, admin): + body = client.get( + "/api/records/fence-fund/disclosure", headers=auth(admin) + ).json() + rungs = {rung["profile"]: rung for rung in body["profiles"]} + assert set(rungs) == {profile.slug for profile in Profile} + + # The matrix is only honest if the numbers move in one direction. + sizes = [rungs[profile.slug]["bytes"] for profile in Profile] + assert sizes == sorted(sizes), "a wider profile shipped fewer bytes" + + def test_narrow_rungs_are_missing_the_keys_they_forbid(self, client, seeded, admin): + body = client.get( + "/api/records/fence-fund/disclosure", headers=auth(admin) + ).json() + rungs = {rung["profile"]: rung for rung in body["profiles"]} + + assert "positions" not in rungs["full_detail"]["keys"] + assert "positions" in rungs["full_plus_positions"]["keys"] + for absent in ("evidence", "nav_series", "merkle_leaves", "findings"): + assert absent not in rungs["summary"]["keys"] + + def test_counts_match_what_the_preview_actually_returns(self, client, seeded, admin): + """The matrix is a claim about the payload. Check it against the payload.""" + matrix = client.get( + "/api/records/fence-fund/disclosure", headers=auth(admin) + ).json() + for rung in matrix["profiles"]: + payload = client.get( + f"/api/records/fence-fund/preview/{rung['profile']}", headers=auth(admin) + ).json() + assert len(payload["metrics"]) == rung["counts"]["metrics"] + assert len(payload.get("evidence", [])) == rung["counts"]["evidence"] + assert len(payload.get("positions", [])) == rung["counts"]["positions"] + + def test_unknown_profile_is_refused(self, client, seeded, admin): + response = client.get( + "/api/records/fence-fund/preview/everything", headers=auth(admin) + ) + assert response.status_code == 422 + + +class TestOwnerView: + def test_owner_sees_the_widest_projection(self, client, seeded, admin): + payload = client.get( + "/api/records/fence-fund/owner", headers=auth(admin) + ).json() + assert payload["state"] == "owner" + assert payload["profile"] == "full_plus_positions" + for section in ("metrics", "evidence", "nav_series", "merkle_leaves"): + assert section in payload + + def test_owner_view_carries_no_watermark(self, client, seeded, admin): + """The watermark identifies a viewer under NDA. The manager is not one.""" + payload = client.get( + "/api/records/fence-fund/owner", headers=auth(admin) + ).json() + assert "watermark" not in payload + + def test_previewing_does_not_write_an_access_event(self, session, client, seeded, admin): + """Previewing is the manager looking at their own record. Logging it as + an allocator view would put a fiction in an append-only log.""" + from adapters import db + + before = session.query(db.AccessEvent).count() + client.get("/api/records/fence-fund/preview/full_detail", headers=auth(admin)) + session.expire_all() + assert session.query(db.AccessEvent).count() == before + + +class TestInviteList: + def test_lists_status_and_never_the_token(self, session, client, seeded, admin): + from tests.test_projection import invite_for + + invite_for(session, seeded, Profile.SUMMARY, "tok-owner-list") + rows = client.get( + "/api/records/fence-fund/invites", headers=auth(admin) + ).json() + + assert len(rows) == 1 + assert rows[0]["status"] == "active" + assert rows[0]["profile"] == "summary" + # Only the hash was ever stored, so there is nothing here to leak. + assert "tok-owner-list" not in str(rows) + assert "token" not in rows[0] + + def test_revoked_invite_reads_as_revoked(self, session, client, seeded, admin): + from tests.test_projection import invite_for + + invite_for(session, seeded, Profile.SUMMARY, "tok-owner-revoke") + rows = client.get( + "/api/records/fence-fund/invites", headers=auth(admin) + ).json() + client.post(f"/api/invites/{rows[0]['id']}/revoke", headers=auth(admin)) + + after = client.get( + "/api/records/fence-fund/invites", headers=auth(admin) + ).json() + assert after[0]["status"] == "revoked" + # And the link stops opening, which is the point of the button. + assert client.get("/api/view/tok-owner-revoke").status_code == 403 + + +class TestSeats: + """Fund and portfolio manager are two seats, not one with a nicer label. + + The interesting case is the negative one: a PM holding a perfectly valid + token must not be able to read the pod next door, and must not be able to + learn it exists. Everything else in the console is a consequence of that. + """ + + def test_a_pm_token_opens_its_own_pod(self, client, admin, seeded): # noqa: F811 + token = pm_token("fence-fund") + response = client.get("/api/records/fence-fund/owner", headers=auth(token)) + assert response.status_code == 200 + + def test_a_pm_cannot_reach_the_pod_next_door(self, client, admin, seeded): # noqa: F811 + token = pm_token("some-other-pod") + for path in ( + "/api/records/fence-fund/owner", + "/api/records/fence-fund/disclosure", + "/api/records/fence-fund/invites", + "/api/records/fence-fund/access-log", + "/api/records/fence-fund/preview/summary", + ): + response = client.get(path, headers=auth(token)) + # 404 rather than 403: a refusal that distinguishes "not yours" from + # "no such thing" leaks the roster one guess at a time. + assert response.status_code == 404, path + + def test_a_pm_token_is_not_forgeable_from_a_sibling(self, client, admin, seeded): # noqa: F811 + """Knowing your own token tells you nothing about anyone else's.""" + mine = pm_token("fence-fund") + forged = mine.replace("fence-fund", "some-other-pod") + assert client.get("/api/me", headers=auth(forged)).status_code == 401 + + def test_the_roster_is_filtered_on_the_server(self, client, admin, seeded): # noqa: F811 + fund = client.get("/api/me", headers=auth(admin)).json() + assert fund["role"] == "fund" + assert any(r["slug"] == "fence-fund" for r in fund["records"]) + + pm = client.get("/api/me", headers=auth(pm_token("fence-fund"))).json() + assert pm["role"] == "pm" + assert [r["slug"] for r in pm["records"]] == ["fence-fund"] + + def test_only_the_fund_can_take_on_a_new_pod(self, client, admin, seeded): # noqa: F811 + body = {"slug": "brand-new-pod", "name": "Brand New"} + assert client.post("/api/records", json=body, headers=auth(pm_token("fence-fund"))).status_code == 403 diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py new file mode 100644 index 0000000..3c39d79 --- /dev/null +++ b/tests/test_pipeline.py @@ -0,0 +1,170 @@ +"""The pipeline end to end, including the tamper drill from §10.""" + +from __future__ import annotations + +from sqlalchemy import select + +from adapters import db +from adapters.chain import record_key +from core.metrics import Config, TWO_AND_TWENTY +from core.tiers import Tier +from core.types import Channel +from worker import pipeline + + +def make_record(session, slug: str) -> db.Record: + entity = db.Entity(name=slug) + session.add(entity) + session.flush() + row = db.Record( + entity_id=entity.id, + slug=slug, + name=slug, + chain_key=record_key(slug), + viewer_salt="salt", + ) + session.add(row) + session.flush() + return row + + +def ingest(session, record, fixture, channel=Channel.EML_UPLOAD): + return pipeline.ingest( + session, + entity_id=record.entity_id, + record_id=record.id, + raw=fixture.raw if hasattr(fixture, "raw") else fixture, + channel=channel, + filename=getattr(fixture, "filename", "upload.bin"), + ) + + +class TestIngest: + def test_a_signed_statement_lands_as_source_signed_facts(self, session, record, corpus): + outcome = ingest(session, record, corpus.by_name("statement-2026-07")) + assert outcome.verified is True + assert outcome.tier is Tier.SOURCE_SIGNED + assert outcome.facts_written >= 1 + + facts = session.scalars(select(db.FactRow)).all() + assert {f.tier for f in facts} == {"source_signed"} + + def test_re_ingesting_identical_bytes_is_a_no_op(self, session, record, corpus): + first = ingest(session, record, corpus.by_name("statement-2026-07")) + second = ingest(session, record, corpus.by_name("statement-2026-07")) + assert second.duplicate is True + assert second.document_id == first.document_id + assert session.scalar(select(db.Document).where(db.Document.id == first.document_id)) + + def test_the_captured_dns_key_is_stored(self, session, record, corpus): + """Without this column the whole DKIM story has a two-year shelf life.""" + ingest(session, record, corpus.by_name("statement-2026-07")) + verdict = session.scalar(select(db.DkimVerdictRow)) + assert verdict.dns_txt_record is not None + assert "p=" in verdict.dns_txt_record + assert verdict.dns_captured_at is not None + + def test_a_csv_can_never_reach_source_signed(self, session, record): + outcome = ingest( + session, + record, + b"as_of,nav,currency\n2026-07-31,15488330.00,USD\n", + channel=Channel.CSV_UPLOAD, + ) + assert outcome.tier is Tier.SELF_REPORTED + assert outcome.facts_written == 1 + + def test_an_unverified_document_is_still_parsed(self, session, record, corpus): + """Refusing to read a tampered document would hide the tamper rather + than grade it. It parses, and it parses as self-reported.""" + outcome = ingest(session, record, corpus.by_name("tampered-body")) + assert outcome.verified is False + assert outcome.tier is Tier.SELF_REPORTED + assert outcome.extraction_status == "ok" + assert outcome.facts_written > 0 + + +class TestTamperDrill: + """Flip a byte in a stored document. The verdict must fail, the tier must + drop, and the recomputed root must differ from the anchored one.""" + + def test_one_flipped_byte_changes_everything(self, session, corpus): + clean = make_record(session, "clean") + dirty = make_record(session, "dirty") + + good = ingest(session, clean, corpus.by_name("trade-confirmation")) + bad = ingest(session, dirty, corpus.by_name("tampered-body")) + session.flush() + + # 1. the verdict fails + assert good.verified is True + assert bad.verified is False + + # 2. the tier drops + assert good.tier is Tier.SOURCE_SIGNED + assert bad.tier is Tier.SELF_REPORTED + + # 3. the recomputed root differs + clean_snapshot = pipeline.rebuild(session, record_id=clean.id, anchor=False) + dirty_snapshot = pipeline.rebuild(session, record_id=dirty.id, anchor=False) + assert clean_snapshot.snapshot.root_hex != dirty_snapshot.snapshot.root_hex + + # 4. and the record says so without being asked + assert dirty_snapshot.snapshot.tier is Tier.SELF_REPORTED + assert any(f.kind == "weak_evidence" for f in dirty_snapshot.findings) + + +class TestSnapshot: + def test_rejected_documents_are_still_committed(self, session, record, corpus): + """A message we rejected is evidence of the rejection. Dropping it from + the tree would let us quietly forget having seen it.""" + ingest(session, record, corpus.by_name("statement-2026-06")) + ingest(session, record, corpus.by_name("statement-2026-07")) + ingest(session, record, corpus.by_name("l-tag-appended")) + session.flush() + + outcome = pipeline.rebuild(session, record_id=record.id, anchor=False) + kinds = [leaf.kind for leaf in outcome.snapshot.leaves] + assert kinds.count("document") == 3 + assert kinds.count("dkim_verdict") == 3 + + def test_sequence_increments_and_never_repeats(self, session, record, corpus): + for name in ("statement-2026-05", "statement-2026-06", "statement-2026-07"): + ingest(session, record, corpus.by_name(name)) + session.flush() + + seqs = [ + pipeline.rebuild(session, record_id=record.id, anchor=False).snapshot.seq + for _ in range(3) + ] + assert seqs == [1, 2, 3] + + def test_a_full_record_computes_and_commits(self, session, record, corpus): + for fixture in corpus.fixtures: + if fixture.name.startswith("statement-"): + ingest(session, record, fixture) + session.flush() + + outcome = pipeline.rebuild( + session, + record_id=record.id, + config=Config(fee_model=TWO_AND_TWENTY), + anchor=False, + ) + assert outcome.snapshot.tier is Tier.SOURCE_SIGNED + assert len(outcome.metrics) > 10 + assert {m.basis for m in outcome.metrics} == {"net", "gross"} + # No anchoring key is configured in tests, so this degrades rather + # than breaking. + assert outcome.anchor_status == "not_attempted" + + +class TestReconciliation: + def test_a_missing_month_is_reported_not_hidden(self, session, record, corpus): + for name in ("statement-2025-08", "statement-2025-09", "statement-2025-10", + "statement-2026-01"): + ingest(session, record, corpus.by_name(name)) + session.flush() + + outcome = pipeline.rebuild(session, record_id=record.id, anchor=False) + assert any(f.kind == "series_gap" for f in outcome.findings) diff --git a/tests/test_projection.py b/tests/test_projection.py new file mode 100644 index 0000000..1654a19 --- /dev/null +++ b/tests/test_projection.py @@ -0,0 +1,176 @@ +"""The fence. + +For every disclosure profile, assert the serialized response contains no field +the profile forbids. Deliberately tested against the wire payload rather than +the component: a UI that merely declines to render a field has still sent it, +and anyone can open the network tab. +""" + +from __future__ import annotations + +import json + +import pytest +from fastapi.testclient import TestClient + +from adapters import db +from adapters.chain import record_key +from api.app import app, token_hash +from api.projection import Profile +from core.metrics import Config, TWO_AND_TWENTY +from core.types import Channel +from worker import pipeline + +# Things that must not appear in a payload that is not allowed to carry them. +# Matched as substrings of the raw JSON, so a nested occurrence anywhere counts. +FORBIDDEN: dict[Profile, tuple[str, ...]] = { + Profile.SUMMARY: ( + "sharpe", "sortino", "calmar", "max_drawdown", "volatility_annualized", + "var_historical", "nav_series", "positions", "evidence", "dns_txt_record", + "merkle_leaves", "findings", + ), + Profile.RATIOS_AND_RISK: ( + "nav_series", "positions", "evidence", "dns_txt_record", "merkle_leaves", + ), + Profile.FULL_DETAIL: ("positions",), + Profile.FULL_PLUS_POSITIONS: (), +} + + +@pytest.fixture() +def seeded(session, corpus): + entity = db.Entity(name="Fence Fund") + session.add(entity) + session.flush() + record = db.Record( + entity_id=entity.id, + slug="fence-fund", + name="Fence Fund", + chain_key=record_key("fence-fund"), + viewer_salt="salt", + ) + session.add(record) + session.flush() + + for fixture in corpus.fixtures: + if fixture.name.startswith("statement-"): + pipeline.ingest( + session, + entity_id=entity.id, + record_id=record.id, + raw=fixture.raw, + channel=Channel.EML_UPLOAD, + filename=fixture.filename, + ) + confirmation = corpus.by_name("trade-confirmation") + pipeline.ingest( + session, + entity_id=entity.id, + record_id=record.id, + raw=confirmation.raw, + channel=Channel.EML_UPLOAD, + filename=confirmation.filename, + ) + session.flush() + pipeline.rebuild( + session, record_id=record.id, config=Config(fee_model=TWO_AND_TWENTY), anchor=False + ) + session.commit() + return record + + +def invite_for(session, record, profile: Profile, token: str) -> str: + from datetime import datetime, timedelta, timezone + + session.add( + db.Invite( + record_id=record.id, + viewer_email=f"{profile.slug}@example.com", + profile=profile.slug, + token_hash=token_hash(token), + expires_at=datetime.now(timezone.utc) + timedelta(days=1), + ) + ) + session.commit() + return token + + +@pytest.mark.parametrize("profile", list(Profile)) +def test_forbidden_fields_never_reach_the_wire(session, seeded, profile): + token = invite_for(session, seeded, profile, f"tok-{profile.slug}") + client = TestClient(app) + + client.post(f"/api/view/{token}/nda", json={"accept": True}) + response = client.get(f"/api/view/{token}") + assert response.status_code == 200 + + body = response.text + for forbidden in FORBIDDEN[profile]: + assert forbidden not in body, ( + f"{profile.slug} leaked {forbidden!r} into the serialized payload" + ) + + +def test_nothing_at_all_is_served_before_the_nda(session, seeded): + token = invite_for(session, seeded, Profile.FULL_DETAIL, "tok-gate") + client = TestClient(app) + + body = client.get(f"/api/view/{token}").text + payload = json.loads(body) + assert payload["state"] == "nda_required" + # Not filtered downstream — never assembled. + for forbidden in ("metrics", "tier", "snapshot", "twr", "evidence"): + assert forbidden not in body + + +def test_consent_is_recorded_before_data_is_served(session, seeded): + token = invite_for(session, seeded, Profile.SUMMARY, "tok-consent") + client = TestClient(app) + + assert session.query(db.AccessEvent).count() == 0 + client.post(f"/api/view/{token}/nda", json={"accept": True}) + session.expire_all() + events = session.query(db.AccessEvent).all() + assert any(event.action == "nda_accepted" for event in events) + + +def test_summary_profile_cannot_reach_proofs_or_documents(session, seeded): + token = invite_for(session, seeded, Profile.SUMMARY, "tok-summary-deny") + client = TestClient(app) + client.post(f"/api/view/{token}/nda", json={"accept": True}) + + assert client.get(f"/api/view/{token}/bundle").status_code == 403 + assert client.get(f"/api/view/{token}/proof/{'00' * 32}").status_code == 403 + + +def test_a_revoked_invite_stops_working(session, seeded): + from datetime import datetime, timezone + + token = invite_for(session, seeded, Profile.FULL_DETAIL, "tok-revoke") + client = TestClient(app) + client.post(f"/api/view/{token}/nda", json={"accept": True}) + assert client.get(f"/api/view/{token}").status_code == 200 + + invite = session.query(db.Invite).filter_by(token_hash=token_hash(token)).one() + invite.revoked_at = datetime.now(timezone.utc) + session.commit() + + assert client.get(f"/api/view/{token}").status_code == 403 + + +def test_an_expired_invite_stops_working(session, seeded): + from datetime import datetime, timedelta, timezone + + token = invite_for(session, seeded, Profile.FULL_DETAIL, "tok-expire") + invite = session.query(db.Invite).filter_by(token_hash=token_hash(token)).one() + invite.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + session.commit() + + assert TestClient(app).get(f"/api/view/{token}").status_code == 403 + + +def test_only_the_hash_of_a_token_is_stored(session, seeded): + token = invite_for(session, seeded, Profile.SUMMARY, "tok-secret-value") + rows = session.query(db.Invite).all() + # A leaked database should not hand anyone a working key. + assert all(token not in (row.token_hash or "") for row in rows) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..b2c792d --- /dev/null +++ b/uv.lock @@ -0,0 +1,2273 @@ +version = 1 +revision = 1 +requires-python = ">=3.12" + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038 }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690 }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484 }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949 }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282 }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511 }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680 }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646 }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122 }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127 }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210 }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848 }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102 }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205 }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219 }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629 }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481 }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845 }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050 }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269 }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166 }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263 }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299 }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235 }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883 }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473 }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190 }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478 }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092 }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546 }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250 }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289 }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706 }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652 }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239 }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161 }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759 }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025 }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477 }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069 }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518 }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676 }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067 }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774 }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134 }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201 }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766 }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557 }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168 }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957 }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149 }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685 }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911 }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929 }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112 }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500 }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940 }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413 }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748 }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564 }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080 }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813 }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872 }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030 }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669 }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403 }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413 }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135 }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742 }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371 }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623 }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515 }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906 }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849 }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496 }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746 }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810 }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895 }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833 }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251 }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180 }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528 }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280 }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 }, +] + +[[package]] +name = "alembic" +version = "1.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/01/a48dab7827ac4421272399f7ed9a2ec17edd12c8bcde4417bd7b6821b71a/alembic-1.19.0.tar.gz", hash = "sha256:6487c612fc719dcfa22b17d2dd5b2b458929641e6aa2f0b65b135727f5e6d501", size = 2069906 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/79/59ab6f1fe72eee229de91aa393225389a85df12a0fbbbfa264efcf6d7872/alembic-1.19.0-py3-none-any.whl", hash = "sha256:cf839d3849116aab3cc047e09c6968b9bd6b2fde61b6bb7c1e97352fe5503580", size = 265738 }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302 }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427 }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813 }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 }, +] + +[[package]] +name = "bitarray" +version = "3.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/c2/ac331091a307bf9f56b7a0f9a8fb4916158bf8dae3a97edebd91f43c985c/bitarray-3.10.1.tar.gz", hash = "sha256:c33e48906407ab3d0edb96cc5ab2a599bda5dd04704ebcd9b3e0eedce7310e0a", size = 168648 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/ce/c2630dfef2d10460e67083c1bf8e3422a8f7d66fc75b02a3d7026e372e6b/bitarray-3.10.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d050414e41e67dc2f41a73a4755f322d043e0c1100d53bc2f9d7d3ead742a86b", size = 164121 }, + { url = "https://files.pythonhosted.org/packages/4b/e6/b0861dc786fa06a5d942c424410d41ec245093f2818b8c69d499c999af24/bitarray-3.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23b8631479fea515ea22c3992a4d6ded44e0475237b652aaec9c9f1c70a72179", size = 160224 }, + { url = "https://files.pythonhosted.org/packages/9c/6f/23d33d381840ade968b92b683a5f3c9e882a58b731705c0b8b666ce214f8/bitarray-3.10.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54db9a52b2f6dfe0e4549ac1842c9551f030bfb929474d18e7b6cd83e05cfd55", size = 369452 }, + { url = "https://files.pythonhosted.org/packages/9e/02/b4bfdbd22dc0ac9bd853dd43681d8c1aa93c8ee93021eb2dc8f99edf6204/bitarray-3.10.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:072093df2829ae426fd73c49761dee9e72d68d42a08102d61d04443413da96c1", size = 389854 }, + { url = "https://files.pythonhosted.org/packages/eb/7b/77c6f9d8b7f2eadabcb82871812eca7417b55675f1dab5d8f3cc1163ce3e/bitarray-3.10.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0dae569d644af8e28576e2f750985aa480fc51a810ba0e3e2cb1bee72619a6ed", size = 404371 }, + { url = "https://files.pythonhosted.org/packages/9e/96/cb01be7ef83c45c2578c58735898375e5d43b32cec6b5e6e6c4081a6424a/bitarray-3.10.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfaadcc4d72cb2855116ac7d164d62d87f668a43bc4f9034a21676688325ac80", size = 373703 }, + { url = "https://files.pythonhosted.org/packages/22/18/d9816a6fdac717869896c63bb9e5b60be2470188f0288ee60b3cdf8dd076/bitarray-3.10.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7d6d66564add2d5f9405e754058a0acbbd4475d3de5b607c2f8fc32e82d6b000", size = 367129 }, + { url = "https://files.pythonhosted.org/packages/83/58/b26cad93fff9fd761a82f22c33ea91a55f9849210b358730d34e3a0becf1/bitarray-3.10.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1ed01b4b692abbfde7461314f473877604846195a06a2e155c5ec1bb7f1adc9c", size = 387259 }, + { url = "https://files.pythonhosted.org/packages/8e/40/9f09360d18197b1d1c58d31e12a305ddf019d31a1b8c3c3a38451ac1c7a3/bitarray-3.10.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:6e043d9d35d78891aa6e096f59c850c549d7da03c2a706b79834073806d170d7", size = 385708 }, + { url = "https://files.pythonhosted.org/packages/c2/2e/4f158817042ff2a5ae7970bdc05ee8b1b09d695c10dba457b135618ba29c/bitarray-3.10.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8474e2ba90091424f1e3f05740939d2bdcce1319ad63ec4aba832910dffb4257", size = 370255 }, + { url = "https://files.pythonhosted.org/packages/65/c2/5195493edc0a741b42dbcf1e95b7c15ece4c9a36179a67d2d7ebb461efe1/bitarray-3.10.1-cp312-cp312-win32.whl", hash = "sha256:8d3dcc6e5c24ba7b6f581446b153fd8575b373149bba6fbcbdc2f109d6a30417", size = 155969 }, + { url = "https://files.pythonhosted.org/packages/39/bc/9f42846bf195b4fefebf95b26ebdb6f81e18871058caf3e470b0a973cd94/bitarray-3.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:3138fc1695f4fc540ce8409e5478495d3ec1796ffcd2ec8254443cd2f6a779be", size = 165510 }, + { url = "https://files.pythonhosted.org/packages/94/79/8da80c98f44b629721cd2fdf998f48112539f73f58f1c23bdeefbced03c6/bitarray-3.10.1-cp312-cp312-win_arm64.whl", hash = "sha256:019d072e59b8f8cc8524794cccef857a31138e065c2cdbb902dde21f3e7e9744", size = 161195 }, + { url = "https://files.pythonhosted.org/packages/f8/47/fa4457d3ba8af856c6d6ab409db28301d986726c92c29faed316bb973aff/bitarray-3.10.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b1edaf829d5fbf0640c211ce83171ade249fc4ea22ac5b87143697e443495c18", size = 163835 }, + { url = "https://files.pythonhosted.org/packages/b9/dd/e909115ca81594fbd291c0240fb8727261356bfb5ff8edbf281d6fb7bed3/bitarray-3.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1174731b7c182cdc2a70ed860b98a912532fa9cd750459916beddbaa98a18c4", size = 159955 }, + { url = "https://files.pythonhosted.org/packages/6d/92/bbe15d9da706dbcce9e11569d244930baafa689740aa9746355c5e4523f6/bitarray-3.10.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b064620234f38ded80c455f2c77d62af932fe5fc14e0549ce5c41613d5509f1", size = 367725 }, + { url = "https://files.pythonhosted.org/packages/13/6f/caf98c29374aaff77d2ef74d95cc5531cfa90dd102bd317c64b08629b0bb/bitarray-3.10.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:41d6b9670792383e6c604cf96dae9de44a79cbb009068f44bd1350edf4db9283", size = 388403 }, + { url = "https://files.pythonhosted.org/packages/af/5a/6cd2f2fc8a4e94c29f2239c5f9a475560dd7feaf548b78306c8b1c9d345d/bitarray-3.10.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9601cac1be19a87056c3ebb53a6fd0e695b4c2f2482c95fbc21f45ac737b384f", size = 402344 }, + { url = "https://files.pythonhosted.org/packages/e6/62/0049b42dbfa5cb8d584a930a611b11073ad74a34b12b0e1dd8542b56b05c/bitarray-3.10.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4145177fe23045e7f416eb7a2b12f34678b5e67e7efed619f79b384b85dd2d4a", size = 371608 }, + { url = "https://files.pythonhosted.org/packages/a4/d2/372e8eb8ee0f02215d194136607b5923b44b5983aebada73e82433704aa7/bitarray-3.10.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:47ad63d66add4a1d39c75377b407c22027e72100a60f06c5bf854c6064c74875", size = 365103 }, + { url = "https://files.pythonhosted.org/packages/02/f6/97a561b8798b8f41f4b18b562474d8887df96d24b2bf5d0f978346bac6e8/bitarray-3.10.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0de09c7c2e366371546de3d88b8d4d5f7d2be38eebafd775981c46919171a0b4", size = 385059 }, + { url = "https://files.pythonhosted.org/packages/cd/63/b9a3885b9c9a9f1816fcea765f33148c2e9c411c40faa3c00f2bd9dcf035/bitarray-3.10.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:66ac3632d2b79e57f0b762be0f331d871ff965e119a54e6e378025614736beb3", size = 383637 }, + { url = "https://files.pythonhosted.org/packages/70/65/6c248b95306519a1eb56b03ce611dce96c999c70a2620d6f40dc21c2ba6d/bitarray-3.10.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5bbb74b86f4b7b017dd29161b6f05f32f7b49f3076b274de3cafb3180e11c990", size = 368124 }, + { url = "https://files.pythonhosted.org/packages/a3/48/5ed21f63cb07aaf688b1609a84ffe95508f5d07ca89bf7270c0a09914aec/bitarray-3.10.1-cp313-cp313-win32.whl", hash = "sha256:e7bc260f090a57e5963427e29c47b32a5acaf89500e40e68809d981348e0c893", size = 155825 }, + { url = "https://files.pythonhosted.org/packages/bf/0e/f14360ee3929faeb5bd7a86b3b97e92558c48864ff210ca13704a27f5ef5/bitarray-3.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:a9a47f8fe495395176f694c2b3454848ff750783c599b7ad98c689f5c2065892", size = 165360 }, + { url = "https://files.pythonhosted.org/packages/ba/40/7211826b639938c85fe189d984263959713a8955c284d6247a2295915a46/bitarray-3.10.1-cp313-cp313-win_arm64.whl", hash = "sha256:13a58b399414422e9c0462f069511992044ae39ae4b33b176726535a0ce3fb06", size = 160977 }, + { url = "https://files.pythonhosted.org/packages/bf/19/8309c1d9817311cdc90e6d79cc2df899114c11be1bc92a79b66ebea22596/bitarray-3.10.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:745b54b772c5e399f37fb22cf09d73f0ff1bc7dc2e25308628c9d3bb39a99e04", size = 163832 }, + { url = "https://files.pythonhosted.org/packages/eb/1e/1b19f398a6ab37fccaabb8af725e8bf9740c9bf6c25cde624e6731e4ec32/bitarray-3.10.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0c03d3d90ec3abe36be8ef6c01a0dd7c47bede4a36a16d0c0bda9d152725219", size = 160209 }, + { url = "https://files.pythonhosted.org/packages/9c/5b/3357c5e4098b98c84003b3112ef488c23b8c8c7b606353c1f49da30e9eec/bitarray-3.10.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6020bfca6e85b3ba551cc056ddb5cb76ae050bcad840fc541520cc3847d89a68", size = 367651 }, + { url = "https://files.pythonhosted.org/packages/f4/71/04ddd055a29052a0780421922ab47f99488308f4bfde13153a5f3597cf21/bitarray-3.10.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:067122845b0d8e9d820a390c3516160a699dd6c31c2c71028cc787fcf0f1b632", size = 388405 }, + { url = "https://files.pythonhosted.org/packages/b5/93/4e121148ad8d0155929764810ba6ac9eb689c75f192fa8997082c9425b17/bitarray-3.10.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0531dce521e00d0d9ee36984f8b198bcd3455f1b8b3cc635efe21114b88e74b6", size = 401716 }, + { url = "https://files.pythonhosted.org/packages/e8/f3/5d43ffc866b4398080452bc301ad39bacc87b608f9fa83ec55bce94b8b6d/bitarray-3.10.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6de68b321bcbd8f2cde7c84b0afca22855e3671ba750fc4610ae4f5cb78b57f", size = 371169 }, + { url = "https://files.pythonhosted.org/packages/11/e4/f236234097a31369d22dd65b2f0d967d6fe63c20e9eb8a59e96e20768795/bitarray-3.10.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:11be8151782d755c60d65d53f81bc33f7c64dca0a5688657a8978afc667aad00", size = 365065 }, + { url = "https://files.pythonhosted.org/packages/49/e0/75770317650289958b3cf9a6b98c747c3c1d24dc2b6c3c60078dbc5affb7/bitarray-3.10.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:e5dc3377a2ae3405961218dca5402ca4b7acf96d1873ffb312a4a21a7c3788b7", size = 385277 }, + { url = "https://files.pythonhosted.org/packages/a7/85/12255d8187f59776a24ce6247624ba1196c330a44396ab8cd6a87d511b0d/bitarray-3.10.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2454206e6066dabace55bca254889d26f6d77fd91693c35efe503617c844e62", size = 383409 }, + { url = "https://files.pythonhosted.org/packages/32/f5/fb2a1541248ec45683e1c48e320e49a61ef43ce23d9b59c2dabbb0000fb4/bitarray-3.10.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b2be5b44ce2e2b92d5d1a0eab0f3b2ddb9caa2ed4efdee4059a6a19c8b2da7b5", size = 367818 }, + { url = "https://files.pythonhosted.org/packages/7b/0d/6af949fea9d726e6814898782efc55da0dd7c7eb5d305bd96aa5843f3d64/bitarray-3.10.1-cp314-cp314-win32.whl", hash = "sha256:e37c125a287de5e31d973fa77ed044f2438e413d98c52d8f15533beaad410fef", size = 154619 }, + { url = "https://files.pythonhosted.org/packages/ad/46/ee499430e89062eaa79c1afc16ac1e9bf93a53a9beddcd9195b4abf43215/bitarray-3.10.1-cp314-cp314-win_amd64.whl", hash = "sha256:d85840fd0999ff22a26b8bd0a6e4695d85ea8515af9e2be9f37afffd4ce7d12d", size = 164313 }, + { url = "https://files.pythonhosted.org/packages/b9/f5/dcd182ea4cfd11965917700e1944ce9ece1c255b90b03ba9e1e61bdc04ca/bitarray-3.10.1-cp314-cp314-win_arm64.whl", hash = "sha256:c9cd67a2a85f4c897b73714d4f856838c587d653e2cd67dba5a555f16390ef20", size = 160470 }, + { url = "https://files.pythonhosted.org/packages/1c/89/1269cf6de1ceee6de98f22dfa6e10885014bc7580f81b5c827fae8a398e4/bitarray-3.10.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c93b6e77c5f16d28ef8d25551b6e6eaf162de51e76ed182c2186de14d367487c", size = 167063 }, + { url = "https://files.pythonhosted.org/packages/17/1a/05ee259804b83c6a967beb1fe39e48ff3f6afa2abebe4faf2a8a4d6c7e9a/bitarray-3.10.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:333f57b1095d89d5d71dd862954457f288462fe7c69bccfe416e56812fd8f037", size = 163574 }, + { url = "https://files.pythonhosted.org/packages/d8/ea/dd37423c9e4b44c7234010c7fbd45e196e39381905314d3662790011c99a/bitarray-3.10.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a989ad017ac6aa2512e17f478d953de2a457bdfc25f5dc974d1d0caedce42921", size = 386189 }, + { url = "https://files.pythonhosted.org/packages/18/f0/caf35ae9d79b87be8b7b2be36015ab65ace9a4ad770a1862932636986c1a/bitarray-3.10.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e2fe3018890da7c0c80a06259977ea7d112d3d06489bfad8209486d6aafafc8f", size = 405403 }, + { url = "https://files.pythonhosted.org/packages/92/72/d7c217ac293353fc9a8b85e50c54c012eb4793c5e868cdcbd964a54ac0f5/bitarray-3.10.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b02744ea565e0878727ea5dcd9373397ade9fb8a9bffb81f0c0b79859e5b433", size = 418890 }, + { url = "https://files.pythonhosted.org/packages/c8/5f/3d9afb63f87e3ab366de7f6934c55d5784ac51b1e3cae30b013bebfd0d89/bitarray-3.10.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:587727a15fe5d9b7d4938b816028d39a51b4660bf564cafd625da3a3457fb75e", size = 387815 }, + { url = "https://files.pythonhosted.org/packages/7e/c1/e8472b8a119e30a3317476beb2c3175421b936315641dccfaf1c6551add4/bitarray-3.10.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:11d9102d42b549a702852afb21df481eec7bf1cf870a04e74f9f26652ed0ad0e", size = 382009 }, + { url = "https://files.pythonhosted.org/packages/14/e7/363bc1435f64c19c71904b969f51dfd8d0a2afa815c1d976a6c2140f148d/bitarray-3.10.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c1e35dd7aa6e9a0c11bf7ab25ec26627db0111250632b2df859ce5fcd00b4178", size = 401433 }, + { url = "https://files.pythonhosted.org/packages/28/09/2cb33a28fe665a8e4541db7f28fb241b44e8a8f8159cb9baec81c85e2c64/bitarray-3.10.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a11e3a8806f221598b05714181c79d4ffbeb984136231b4cdce2435a17772e67", size = 399566 }, + { url = "https://files.pythonhosted.org/packages/f9/50/e65b0069c4cca0c964ca35b9b645e9f74afdba5941f14d2c266633d228eb/bitarray-3.10.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:95f8913893cf8c9a7c161b9bcd922e42e4901ea2a768b763e21fa8f9765917ee", size = 383813 }, + { url = "https://files.pythonhosted.org/packages/de/9e/3f86a88b7814e61a6a04e7c85f80dd8c603d7027cc8fd959ad6f611e6406/bitarray-3.10.1-cp314-cp314t-win32.whl", hash = "sha256:06a924dad0562df6bf8a22b22a863b3b9f9f73d118ca1bdbd69322e05bcae0df", size = 157972 }, + { url = "https://files.pythonhosted.org/packages/86/81/dff4b97c2ae91062544e95d52f5c447649c01fc7b1ed56c2f7dc7b67e9f8/bitarray-3.10.1-cp314-cp314t-win_amd64.whl", hash = "sha256:a0cf84cfbefc6a8238062af0ff06688e5b63884ff5c2eaf4c9ca5f74423c4c3c", size = 167865 }, + { url = "https://files.pythonhosted.org/packages/70/3e/e95e7891e628d90c69a4b9e11af7aacd4590b23a36965f4a21b3ab5917f8/bitarray-3.10.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d5b6d177473ad758637a9d03205f99e3b9a16fdec949b32a8814e8ecf6a3523d", size = 163676 }, +] + +[[package]] +name = "boto3" +version = "1.43.67" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/bf/ef5de9b55523bc2141d072fbe6614627088e3e6f97b47850216e99de6a1c/boto3-1.43.67.tar.gz", hash = "sha256:75fe983b70d39cfdc274dc51f9bb02b8a0a104bdad4fa073c1af85a35e707c91", size = 112665 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/0f/b41f8968452dc6bf3b83f15f5e9f76a27fd93e246906aeb2e0555f494375/boto3-1.43.67-py3-none-any.whl", hash = "sha256:082cf9df068168cb44028a1703822374c1eb7e48fa49470d7e8a76f0c977d0bc", size = 140025 }, +] + +[[package]] +name = "botocore" +version = "1.43.67" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/1c/3a75deae60e36bd0ee5c27d040384756b2ea1c90bd7c8c9658335a18b4f5/botocore-1.43.67.tar.gz", hash = "sha256:6fe5cfa0c8676ba809efe505b618ec00f30d1af2d014bf316a7aa4ee86accb20", size = 15889514 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/be/38af8e96f3d200c9d34eab8e9f69a4468388ed6030ea04ff012974e5af5a/botocore-1.43.67-py3-none-any.whl", hash = "sha256:48ab8e9fac26fbc2a700d57010251003e6a5f731cf74d8540fb796bc8f3fc0ef", size = 15575924 }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983 }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821 }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719 }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799 }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389 }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249 }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775 }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822 }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232 }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597 }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292 }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919 }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093 }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248 }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908 }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805 }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764 }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722 }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369 }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175 }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670 }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824 }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148 }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564 }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263 }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688 }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078 }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064 }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720 }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964 }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962 }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328 }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985 }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530 }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525 }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053 }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213 }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682 }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949 }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947 }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504 }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259 }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864 }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538 }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688 }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803 }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763 }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688 }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868 }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104 }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402 }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043 }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737 }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933 }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002 }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271 }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919 }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529 }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630 }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134 }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197 }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683 }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897 }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935 }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464 }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262 }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779 }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520 }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673 }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835 }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705 }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539 }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707 }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772 }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300 }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802 }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171 }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075 }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256 }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784 }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928 }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489 }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267 }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030 }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185 }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557 }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665 }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688 }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982 }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460 }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003 }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149 }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901 }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176 }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356 }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614 }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991 }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622 }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947 }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594 }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253 }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898 }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718 }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519 }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143 }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742 }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191 }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328 }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406 }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157 }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095 }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796 }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334 }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848 }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022 }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590 }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584 }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224 }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667 }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179 }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372 }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222 }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958 }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580 }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620 }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037 }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538 }, +] + +[[package]] +name = "ckzg" +version = "2.1.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/88/552337d9fc69dc85fb6102c18b73a9f3f77efb39bb9a0c1a8c61bbdd7274/ckzg-2.1.8.tar.gz", hash = "sha256:d7bef6b425dca6995457fc59fc5b30211d9b28cbbeee0e7a7bef1372e13f29ca", size = 1128002 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/46/4d9a53c00c24eca9055f2adf64382217b49f1eeea5af7d91915a8e74d236/ckzg-2.1.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:98abe138d79886e3e1fbbaf05cdf0702a4351f242ad1a8b4802343c7ba149faa", size = 96626 }, + { url = "https://files.pythonhosted.org/packages/b0/90/f8a9befa5416fa3cd89ee04d76f55ac1990862d19d87dab124c1583e147b/ckzg-2.1.8-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:2fe01dad7c968bcdf3c063c5192bc7d7d59f66358afb5c99554e5ce2435a95aa", size = 180892 }, + { url = "https://files.pythonhosted.org/packages/71/42/79280e02d7a8f7b4f97d581b013024c8695ff6a54cd4851f033d8a4733b7/ckzg-2.1.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bdb7f51ee3cf8e45451bee8c6dce975fddadfe231174d8de9c27a3aa27741b8", size = 166921 }, + { url = "https://files.pythonhosted.org/packages/43/af/d0ed7c7b2babfa76e91190a90e8e1f93729d63370493c2c79acfe9002abe/ckzg-2.1.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10339d23e36b8a0a4e6fda7f6c72d6b2fd4e1506f7b64a661ba8c706ee33f335", size = 176775 }, + { url = "https://files.pythonhosted.org/packages/90/7e/3600096f33afa628e905cbb240733e46561d05b4bb3148f05bde0afe2208/ckzg-2.1.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a4456c9027f2edcb50a2279d6035ca971a511d8b0025e6659ff407b87ad841ba", size = 174177 }, + { url = "https://files.pythonhosted.org/packages/dc/66/c3cdda51c637852cefd785fc98fb9654dd439ccef09e4a3dd05c9ee498b5/ckzg-2.1.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:88cbec7e2010f64988c249ebb5679b73ccae1c536f4c130d4708bf7b06a8cd69", size = 189432 }, + { url = "https://files.pythonhosted.org/packages/3b/10/a04ac22d843dc5cbe97a6ea4be36db2bb9a001a93e3b3b18ac45693565e6/ckzg-2.1.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4d2581df10bdfaec00fec6daefdeaa438e66582364e1d1b705710e9d749fc47", size = 184231 }, + { url = "https://files.pythonhosted.org/packages/91/94/381f0c9ce5d6514b0141fd55117980c82aad1e8c93c91e5c3d30a5752e52/ckzg-2.1.8-cp312-cp312-win_amd64.whl", hash = "sha256:a30f2b980929e898f0b28aa6bf9ae35e7afd5884e354376ad3744669b7cacf3e", size = 103241 }, + { url = "https://files.pythonhosted.org/packages/aa/d3/d41f083404fedc23721349b6497f8742be2d9b3d1273f23389683e4c65c2/ckzg-2.1.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:26ed4c4d3acbfdb4bfdf1ab1029219657d4565e1c63d36f2695cdfdb5ec0b569", size = 96632 }, + { url = "https://files.pythonhosted.org/packages/f4/9a/7dc7e3673f77a6a7fcb8eb6593a1ad6817c0135f5207b350444a6bc468f0/ckzg-2.1.8-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:a6899908ca3a41e6d2aa19973b398de101a6d64b5189894077ea09a3f508d3fd", size = 180914 }, + { url = "https://files.pythonhosted.org/packages/05/c0/5bbd60263520fec0e5cbcaf25a5ecab3621f9ce980d67d58cadb53f08a46/ckzg-2.1.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3db1ca21685d567eea668925c9f85ebd723db41d24bbecaa2f78d8256e1ba9c6", size = 166976 }, + { url = "https://files.pythonhosted.org/packages/b2/17/dc58a11e582de7906305690801b62faf1b18667521ce6439831472c7bdf8/ckzg-2.1.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f27d7d16be9debf173369248ff06e97fc45826bfb0a743519b49b38539ab6c7", size = 176811 }, + { url = "https://files.pythonhosted.org/packages/2d/50/1be2a98a1b37d0f98c77fddd3528b1f4c8dd4a9d0a07ec519ccd787f5c69/ckzg-2.1.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dadf0cd0c3c19611e8a1188a2a10316b88fbae56d806f75a67cbe846b8b7ec86", size = 174190 }, + { url = "https://files.pythonhosted.org/packages/05/5d/ea050c82c3a86f712eae5ceb50ca3d4b5fa92442707f2023c15a1415e882/ckzg-2.1.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cde13789188b7bac6d5bee308532a7bd60ab5c2feeccdef2f1da41be761c7e04", size = 189470 }, + { url = "https://files.pythonhosted.org/packages/d4/17/98db8284b30f75bccb4f860ee35dfd61d0644d7b0880b95b045bed11b373/ckzg-2.1.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f2737534458547ba8ce89663f833041925b104f7906f17b2338d1b36e6c7c4d", size = 184248 }, + { url = "https://files.pythonhosted.org/packages/66/9a/b979219005a38fa172aaf811516913cda1386fb9c08017f9883ab710f009/ckzg-2.1.8-cp313-cp313-win_amd64.whl", hash = "sha256:10b483ad6937878f03d556d120a43d323dcb3891eb83313aa71087b54559594b", size = 103243 }, + { url = "https://files.pythonhosted.org/packages/45/33/cb5aa31fa8ee8522f28fabfc8abbbb0deb36ae8cb28255060378c6efea04/ckzg-2.1.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f3e4a3ae1ff3ec811b6d2aac7a246524f72a750af95fe7a01550cdd68677d6f", size = 96628 }, + { url = "https://files.pythonhosted.org/packages/a4/42/554f1fadafa3f1100049701c5c7dca9e317f8388607a5ba46f780248218e/ckzg-2.1.8-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:9afe50a28d8d6f130797f0861d4753f76294d983f1e6ead9c17dfa14f8118ad4", size = 180921 }, + { url = "https://files.pythonhosted.org/packages/b7/09/054735d639798c81aefc219ab6adf543cbc33192f127f4c396f16fdeea4c/ckzg-2.1.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da197eef7014997b976ae7bc6e0ad42c02a9faa3a4221d6699e8b777761422f1", size = 167037 }, + { url = "https://files.pythonhosted.org/packages/62/90/a535ec2a40bcbd746c95e762cf788b0b07559958ec58a2f2c55c037003b8/ckzg-2.1.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fef164b9a0c7ed57935bf78bd23e701ba82efcca180e9b29814a94928a2d5880", size = 176800 }, + { url = "https://files.pythonhosted.org/packages/71/f5/be114e08e6d9457c840ff286c58b3478ba445f4d763e36a10c1257074cbc/ckzg-2.1.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c56f588a57d419ac314880931122796e4331be395368c8887ca3ade5c27f539c", size = 174296 }, + { url = "https://files.pythonhosted.org/packages/e2/59/f6b566f79d7910aeb56f1894e40f8c45b01cd34936ab0a05ac335b5d3de4/ckzg-2.1.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:37eecbce59271040dfab736db490a28c0e59f189b404e5820e65531dadf84ddb", size = 189475 }, + { url = "https://files.pythonhosted.org/packages/ae/a9/2f9428c2a662e79e0a3f1006b988b3dd4cd319172847f45220c3b8ab763a/ckzg-2.1.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:acc5d33e42ac852fec08ad1991020a958e2534ac6af346a83f579b553bd0bdfd", size = 184230 }, + { url = "https://files.pythonhosted.org/packages/83/3f/06da6b5c18ae37579c09dbaff45fdb7b8483b0b9b5e6077b55e60c68cade/ckzg-2.1.8-cp314-cp314-win_amd64.whl", hash = "sha256:5eb3b5327dd0cbaaa6551e01a42af9780e998640c57236e460830bf9a6e6f9b4", size = 107624 }, + { url = "https://files.pythonhosted.org/packages/24/d3/6d80b8a9ffca4730edc9932c9bf0652cd241dee597a72373f51b44240bc9/ckzg-2.1.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dbd7021cdc5616df5902ec04876b9af25d17facc39851b4d6630c9c2b209e30a", size = 96896 }, + { url = "https://files.pythonhosted.org/packages/c7/ce/099305aa2abd9700a376b90624ee01fe2180c0d8df2d936b9d5de6afb5bf/ckzg-2.1.8-cp314-cp314t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:5ecbcd887fa97988ddfc3ac4d1951367fa4f6bb25a6f72d449550ea4be45b938", size = 183796 }, + { url = "https://files.pythonhosted.org/packages/03/1e/de72a59a34158e2057ca9522d363be62e880caa8018c98b8cd6da511dada/ckzg-2.1.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaeaed98d7be94f7c215373f90cd06536237d6ec08d48d2be74c630e03edf73c", size = 170089 }, + { url = "https://files.pythonhosted.org/packages/1e/6e/0a1fa7def67b97e3de29163cd76ef180f1976ae02c46ee5aa0bc8746eba1/ckzg-2.1.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1030903bf9989957b73fe30a96516e75a8bc27efb65e1b6e9d3507447bfee06", size = 179605 }, + { url = "https://files.pythonhosted.org/packages/65/c7/f580e449ffe83d1e9281d57a7e903a2409eaaadbd1275f08c02dfa027cf7/ckzg-2.1.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff48587f541b625dd0e471ecc866cf91462100a4429e3c21cf4f099e7dff9150", size = 177148 }, + { url = "https://files.pythonhosted.org/packages/fe/76/ea8cc7f7daa82a4e9cab639102759a5af44b5b6115117e6d9b7e915df3aa/ckzg-2.1.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0580e8a1780d44a85c6edaa44e30a6a85565ad26593becb320d7353bc05e8627", size = 192301 }, + { url = "https://files.pythonhosted.org/packages/b6/03/a047103bdb3d7da7a4a5ecc4926b7001cfbe20bcc60c5a2676a55e8d7cf8/ckzg-2.1.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bc8a259bc3cc321413c3c8eb0789cbe35919f4f4bb32946b0e1a484d4303629c", size = 186786 }, + { url = "https://files.pythonhosted.org/packages/b0/07/8c4165dd469dfaecb6fb5423c0cd150a1d7977c895f8e4d1cfe51bca6b2a/ckzg-2.1.8-cp314-cp314t-win_amd64.whl", hash = "sha256:f41377a2a63330df64ae6f7cd806a288b21c52aa346151fd8f0551b9e0742289", size = 107734 }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252 }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554 }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130 }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244 }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265 }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609 }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517 }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529 }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852 }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462 }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708 }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179 }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395 }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258 }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648 }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442 }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596 }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552 }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113 }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580 }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038 }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110 }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439 }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383 }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772 }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291 }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009 }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252 }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939 }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483 }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599 }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647 }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197 }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095 }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948 }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400 }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208 }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050 }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135 }, +] + +[[package]] +name = "cytoolz" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "toolz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/d4/16916f3dc20a3f5455b63c35dcb260b3716f59ce27a93586804e70e431d5/cytoolz-1.1.0.tar.gz", hash = "sha256:13a7bf254c3c0d28b12e2290b82aed0f0977a4c2a2bf84854fcdc7796a29f3b0", size = 642510 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/ec/01426224f7acf60183d3921b25e1a8e71713d3d39cb464d64ac7aace6ea6/cytoolz-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:99f8e134c9be11649342853ec8c90837af4089fc8ff1e8f9a024a57d1fa08514", size = 1327800 }, + { url = "https://files.pythonhosted.org/packages/b4/07/e07e8fedd332ac9626ad58bea31416dda19bfd14310731fa38b16a97e15f/cytoolz-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0a6f44cf9319c30feb9a50aa513d777ef51efec16f31c404409e7deb8063df64", size = 997118 }, + { url = "https://files.pythonhosted.org/packages/ab/72/c0f766d63ed2f9ea8dc8e1628d385d99b41fb834ce17ac3669e3f91e115d/cytoolz-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:945580dc158c557172fca899a35a99a16fbcebf6db0c77cb6621084bc82189f9", size = 991169 }, + { url = "https://files.pythonhosted.org/packages/df/4b/1f757353d1bf33e56a7391ecc9bc49c1e529803b93a9d2f67fe5f92906fe/cytoolz-1.1.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:257905ec050d04f2f856854620d1e25556fd735064cebd81b460f54939b9f9d5", size = 2700680 }, + { url = "https://files.pythonhosted.org/packages/25/73/9b25bb7ed8d419b9d6ff2ae0b3d06694de79a3f98f5169a1293ff7ad3a3f/cytoolz-1.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82779049f352fb3ab5e8c993ab45edbb6e02efb1f17f0b50f4972c706cc51d76", size = 2824951 }, + { url = "https://files.pythonhosted.org/packages/0c/93/9c787f7c909e75670fff467f2504725d06d8c3f51d6dfe22c55a08c8ccd4/cytoolz-1.1.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7d3e405e435320e08c5a1633afaf285a392e2d9cef35c925d91e2a31dfd7a688", size = 2679635 }, + { url = "https://files.pythonhosted.org/packages/50/aa/9ee92c302cccf7a41a7311b325b51ebeff25d36c1f82bdc1bbe3f58dc947/cytoolz-1.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:923df8f5591e0d20543060c29909c149ab1963a7267037b39eee03a83dbc50a8", size = 2938352 }, + { url = "https://files.pythonhosted.org/packages/6a/a3/3b58c5c1692c3bacd65640d0d5c7267a7ebb76204f7507aec29de7063d2f/cytoolz-1.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:25db9e4862f22ea0ae2e56c8bec9fc9fd756b655ae13e8c7b5625d7ed1c582d4", size = 3022121 }, + { url = "https://files.pythonhosted.org/packages/e1/93/c647bc3334355088c57351a536c2d4a83dd45f7de591fab383975e45bff9/cytoolz-1.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7a98deb11ccd8e5d9f9441ef2ff3352aab52226a2b7d04756caaa53cd612363", size = 2857656 }, + { url = "https://files.pythonhosted.org/packages/b2/c2/43fea146bf4141deea959e19dcddf268c5ed759dec5c2ed4a6941d711933/cytoolz-1.1.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dce4ee9fc99104bc77efdea80f32ca5a650cd653bcc8a1d984a931153d3d9b58", size = 2551284 }, + { url = "https://files.pythonhosted.org/packages/6f/df/cdc7a81ce5cfcde7ef523143d545635fc37e80ccacce140ae58483a21da3/cytoolz-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80d6da158f7d20c15819701bbda1c041f0944ede2f564f5c739b1bc80a9ffb8b", size = 2721673 }, + { url = "https://files.pythonhosted.org/packages/45/be/f8524bb9ad8812ad375e61238dcaa3177628234d1b908ad0b74e3657cafd/cytoolz-1.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3b5c5a192abda123ad45ef716ec9082b4cf7d95e9ada8291c5c2cc5558be858b", size = 2722884 }, + { url = "https://files.pythonhosted.org/packages/23/e6/6bb8e4f9c267ad42d1ff77b6d2e4984665505afae50a216290e1d7311431/cytoolz-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5b399ce7d967b1cb6280250818b786be652aa8ddffd3c0bb5c48c6220d945ab5", size = 2685486 }, + { url = "https://files.pythonhosted.org/packages/d7/dd/88619f9c8d2b682562c0c886bbb7c35720cb83fda2ac9a41bdd14073d9bd/cytoolz-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e7e29a1a03f00b4322196cfe8e2c38da9a6c8d573566052c586df83aacc5663c", size = 2839661 }, + { url = "https://files.pythonhosted.org/packages/b8/8d/4478ebf471ee78dd496d254dc0f4ad729cd8e6ba8257de4f0a98a2838ef2/cytoolz-1.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5291b117d71652a817ec164e7011f18e6a51f8a352cc9a70ed5b976c51102fda", size = 2547095 }, + { url = "https://files.pythonhosted.org/packages/e6/68/f1dea33367b0b3f64e199c230a14a6b6f243c189020effafd31e970ca527/cytoolz-1.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8caef62f846a9011676c51bda9189ae394cdd6bb17f2946ecaedc23243268320", size = 2870901 }, + { url = "https://files.pythonhosted.org/packages/4a/9a/33591c09dfe799b8fb692cf2ad383e2c41ab6593cc960b00d1fc8a145655/cytoolz-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:de425c5a8e3be7bb3a195e19191d28d9eb3c2038046064a92edc4505033ec9cb", size = 2765422 }, + { url = "https://files.pythonhosted.org/packages/60/2b/a8aa233c9416df87f004e57ae4280bd5e1f389b4943d179f01020c6ec629/cytoolz-1.1.0-cp312-cp312-win32.whl", hash = "sha256:296440a870e8d1f2e1d1edf98f60f1532b9d3ab8dfbd4b25ec08cd76311e79e5", size = 901933 }, + { url = "https://files.pythonhosted.org/packages/ad/33/4c9bdf8390dc01d2617c7f11930697157164a52259b6818ddfa2f94f89f4/cytoolz-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:07156987f224c6dac59aa18fb8bf91e1412f5463961862716a3381bf429c8699", size = 947989 }, + { url = "https://files.pythonhosted.org/packages/35/ac/6e2708835875f5acb52318462ed296bf94ed0cb8c7cb70e62fbd03f709e3/cytoolz-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:23e616b38f5b3160c7bb45b0f84a8f3deb4bd26b29fb2dfc716f241c738e27b8", size = 903913 }, + { url = "https://files.pythonhosted.org/packages/71/4a/b3ddb3ee44fe0045e95dd973746f93f033b6f92cce1fc3cbbe24b329943c/cytoolz-1.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:76c9b58555300be6dde87a41faf1f97966d79b9a678b7a526fcff75d28ef4945", size = 976728 }, + { url = "https://files.pythonhosted.org/packages/42/21/a3681434aa425875dd828bb515924b0f12c37a55c7d2bc5c0c5de3aeb0b4/cytoolz-1.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d1d638b10d3144795655e9395566ce35807df09219fd7cacd9e6acbdef67946a", size = 986057 }, + { url = "https://files.pythonhosted.org/packages/d9/cb/efc1b29e211e0670a6953222afaac84dcbba5cb940b130c0e49858978040/cytoolz-1.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:26801c1a165e84786a99e03c9c9973356caaca002d66727b761fb1042878ef06", size = 992632 }, + { url = "https://files.pythonhosted.org/packages/be/b0/e50621d21e939338c97faab651f58ea7fa32101226a91de79ecfb89d71e1/cytoolz-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a9a464542912d3272f6dccc5142df057c71c6a5cbd30439389a732df401afb7", size = 1317534 }, + { url = "https://files.pythonhosted.org/packages/0d/6b/25aa9739b0235a5bc4c1ea293186bc6822a4c6607acfe1422423287e7400/cytoolz-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed6104fa942aa5784bf54f339563de637557e3443b105760bc4de8f16a7fc79b", size = 992336 }, + { url = "https://files.pythonhosted.org/packages/e1/53/5f4deb0ff958805309d135d899c764364c1e8a632ce4994bd7c45fb98df2/cytoolz-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56161f0ab60dc4159ec343509abaf809dc88e85c7e420e354442c62e3e7cbb77", size = 986118 }, + { url = "https://files.pythonhosted.org/packages/1c/e3/f6255b76c8cc0debbe1c0779130777dc0434da6d9b28a90d9f76f8cb67cd/cytoolz-1.1.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:832bd36cc9123535f1945acf6921f8a2a15acc19cfe4065b1c9b985a28671886", size = 2679563 }, + { url = "https://files.pythonhosted.org/packages/59/8a/acc6e39a84e930522b965586ad3a36694f9bf247b23188ee0eb47b1c9ed1/cytoolz-1.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1842636b6e034f229bf084c2bcdcfd36c8437e752eefd2c74ce9e2f10415cb6e", size = 2813020 }, + { url = "https://files.pythonhosted.org/packages/db/f5/0083608286ad1716eda7c41f868e85ac549f6fd6b7646993109fa0bdfd98/cytoolz-1.1.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:823df012ab90d2f2a0f92fea453528539bf71ac1879e518524cd0c86aa6df7b9", size = 2669312 }, + { url = "https://files.pythonhosted.org/packages/47/a8/d16080b575520fe5da00cede1ece4e0a4180ec23f88dcdc6a2f5a90a7f7f/cytoolz-1.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f1fcf9e7e7b3487883ff3f815abc35b89dcc45c4cf81c72b7ee457aa72d197b", size = 2922147 }, + { url = "https://files.pythonhosted.org/packages/7e/bc/716c9c1243701e58cad511eb3937fd550e645293c5ed1907639c5d66f194/cytoolz-1.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4cdb3fa1772116827f263f25b0cdd44c663b6701346a56411960534a06c082de", size = 2981602 }, + { url = "https://files.pythonhosted.org/packages/14/bc/571b232996846b27f4ac0c957dc8bf60261e9b4d0d01c8d955e82329544e/cytoolz-1.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1b5c95041741b81430454db65183e133976f45ac3c03454cfa8147952568529", size = 2830103 }, + { url = "https://files.pythonhosted.org/packages/5b/55/c594afb46ecd78e4b7e1fb92c947ed041807875661ceda73baaf61baba4f/cytoolz-1.1.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b2079fd9f1a65f4c61e6278c8a6d4f85edf30c606df8d5b32f1add88cbbe2286", size = 2533802 }, + { url = "https://files.pythonhosted.org/packages/93/83/1edcf95832555a78fc43b975f3ebe8ceadcc9664dd47fd33747a14df5069/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a92a320d72bef1c7e2d4c6d875125cf57fc38be45feb3fac1bfa64ea401f54a4", size = 2706071 }, + { url = "https://files.pythonhosted.org/packages/e2/df/035a408df87f25cfe3611557818b250126cd2281b2104cd88395de205583/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06d1c79aa51e6a92a90b0e456ebce2288f03dd6a76c7f582bfaa3eda7692e8a5", size = 2707575 }, + { url = "https://files.pythonhosted.org/packages/7a/a4/ef78e13e16e93bf695a9331321d75fbc834a088d941f1c19e6b63314e257/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e1d7be25f6971e986a52b6d3a0da28e1941850985417c35528f6823aef2cfec5", size = 2660486 }, + { url = "https://files.pythonhosted.org/packages/30/7a/2c3d60682b26058d435416c4e90d4a94db854de5be944dfd069ed1be648a/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:964b248edc31efc50a65e9eaa0c845718503823439d2fa5f8d2c7e974c2b5409", size = 2819605 }, + { url = "https://files.pythonhosted.org/packages/45/92/19b722a1d83cc443fbc0c16e0dc376f8a451437890d3d9ee370358cf0709/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c9ff2b3c57c79b65cb5be14a18c6fd4a06d5036fb3f33e973a9f70e9ac13ca28", size = 2533559 }, + { url = "https://files.pythonhosted.org/packages/1d/15/fa3b7891da51115204416f14192081d3dea0eaee091f123fdc1347de8dd1/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:22290b73086af600042d99f5ce52a43d4ad9872c382610413176e19fc1d4fd2d", size = 2839171 }, + { url = "https://files.pythonhosted.org/packages/46/40/d3519d5cd86eebebf1e8b7174ec32dfb6ecec67b48b0cfb92bf226659b5a/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ade74fccd080ea793382968913ee38d7a35c921df435bbf0a6aeecf0d17574", size = 2743379 }, + { url = "https://files.pythonhosted.org/packages/93/e2/a9e7511f0a13fdbefa5bf73cf8e4763878140de9453fd3e50d6ac57b6be7/cytoolz-1.1.0-cp313-cp313-win32.whl", hash = "sha256:db5dbcfda1c00e937426cbf9bdc63c24ebbc358c3263bfcbc1ab4a88dc52aa8e", size = 900844 }, + { url = "https://files.pythonhosted.org/packages/d6/a4/fb7eb403c6a4c81e5a30363f34a71adcc8bf5292dc8ea32e2440aa5668f2/cytoolz-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9e2d3fe3b45c3eb7233746f7aca37789be3dceec3e07dcc406d3e045ea0f7bdc", size = 946461 }, + { url = "https://files.pythonhosted.org/packages/93/bb/1c8c33d353548d240bc6e8677ee8c3560ce5fa2f084e928facf7c35a6dcf/cytoolz-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:32c559f95ff44a9ebcbd934acaa1e6dc8f3e6ffce4762a79a88528064873d6d5", size = 902673 }, + { url = "https://files.pythonhosted.org/packages/c4/ba/4a53acc60f59030fcaf48c7766e3c4c81bd997379425aa45b129396557b5/cytoolz-1.1.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9e2cd93b28f667c5870a070ab2b8bb4397470a85c4b204f2454b0ad001cd1ca3", size = 1372336 }, + { url = "https://files.pythonhosted.org/packages/ac/90/f28fd8ad8319d8f5c8da69a2c29b8cf52a6d2c0161602d92b366d58926ab/cytoolz-1.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f494124e141a9361f31d79875fe7ea459a3be2b9dadd90480427c0c52a0943d4", size = 1011930 }, + { url = "https://files.pythonhosted.org/packages/c9/95/4561c4e0ad1c944f7673d6d916405d68080f10552cfc5d69a1cf2475a9a1/cytoolz-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:53a3262bf221f19437ed544bf8c0e1980c81ac8e2a53d87a9bc075dba943d36f", size = 1020610 }, + { url = "https://files.pythonhosted.org/packages/c3/14/b2e1ffa4995ec36e1372e243411ff36325e4e6d7ffa34eb4098f5357d176/cytoolz-1.1.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:47663e57d3f3f124921f38055e86a1022d0844c444ede2e8f090d3bbf80deb65", size = 2917327 }, + { url = "https://files.pythonhosted.org/packages/4a/29/7cab6c609b4514ac84cca2f7dca6c509977a8fc16d27c3a50e97f105fa6a/cytoolz-1.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5a8755c4104ee4e3d5ba434c543b5f85fdee6a1f1df33d93f518294da793a60", size = 3108951 }, + { url = "https://files.pythonhosted.org/packages/9a/71/1d1103b819458679277206ad07d78ca6b31c4bb88d6463fd193e19bfb270/cytoolz-1.1.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4d96ff3d381423af1b105295f97de86d1db51732c9566eb37378bab6670c5010", size = 2807149 }, + { url = "https://files.pythonhosted.org/packages/1a/d4/3d83a05a21e7d2ed2b9e6daf489999c29934b005de9190272b8a2e3735d0/cytoolz-1.1.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0ec96b3d537cdf47d4e76ded199f7440715f4c71029b45445cff92c1248808c2", size = 3111608 }, + { url = "https://files.pythonhosted.org/packages/51/88/96f68354c3d4af68de41f0db4fe41a23b96a50a4a416636cea325490cfeb/cytoolz-1.1.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:208e2f2ef90a32b0acbff3303d90d89b13570a228d491d2e622a7883a3c68148", size = 3179373 }, + { url = "https://files.pythonhosted.org/packages/ce/50/ed87a5cd8e6f27ffbb64c39e9730e18ec66c37631db2888ae711909f10c9/cytoolz-1.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d416a81bb0bd517558668e49d30a7475b5445f9bbafaab7dcf066f1e9adba36", size = 3003120 }, + { url = "https://files.pythonhosted.org/packages/d3/a7/acde155b050d6eaa8e9c7845c98fc5fb28501568e78e83ebbf44f8855274/cytoolz-1.1.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f32e94c91ffe49af04835ee713ebd8e005c85ebe83e7e1fdcc00f27164c2d636", size = 2703225 }, + { url = "https://files.pythonhosted.org/packages/1b/b6/9d518597c5bdea626b61101e8d2ff94124787a42259dafd9f5fc396f346a/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15d0c6405efc040499c46df44056a5c382f551a7624a41cf3e4c84a96b988a15", size = 2956033 }, + { url = "https://files.pythonhosted.org/packages/89/7a/93e5f860926165538c85e1c5e1670ad3424f158df810f8ccd269da652138/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:bf069c5381d757debae891401b88b3a346ba3a28ca45ba9251103b282463fad8", size = 2862950 }, + { url = "https://files.pythonhosted.org/packages/76/e6/99d6af00487bedc27597b54c9fcbfd5c833a69c6b7a9b9f0fff777bfc7aa/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d5cf15892e63411ec1bd67deff0e84317d974e6ab2cdfefdd4a7cea2989df66", size = 2861757 }, + { url = "https://files.pythonhosted.org/packages/71/ca/adfa1fb7949478135a37755cb8e88c20cd6b75c22a05f1128f05f3ab2c60/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3e3872c21170f8341656f8692f8939e8800dcee6549ad2474d4c817bdefd62cd", size = 2979049 }, + { url = "https://files.pythonhosted.org/packages/70/4c/7bf47a03a4497d500bc73d4204e2d907771a017fa4457741b2a1d7c09319/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b9ddeff8e8fd65eb1fcefa61018100b2b627e759ea6ad275d2e2a93ffac147bf", size = 2699492 }, + { url = "https://files.pythonhosted.org/packages/7e/e7/3d034b0e4817314f07aa465d5864e9b8df9d25cb260a53dd84583e491558/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:02feeeda93e1fa3b33414eb57c2b0aefd1db8f558dd33fdfcce664a0f86056e4", size = 2995646 }, + { url = "https://files.pythonhosted.org/packages/c1/62/be357181c71648d9fe1d1ce91cd42c63457dcf3c158e144416fd51dced83/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d08154ad45349162b6c37f12d5d1b2e6eef338e657b85e1621e4e6a4a69d64cb", size = 2919481 }, + { url = "https://files.pythonhosted.org/packages/62/d5/bf5434fde726c4f80cb99912b2d8e0afa1587557e2a2d7e0315eb942f2de/cytoolz-1.1.0-cp313-cp313t-win32.whl", hash = "sha256:10ae4718a056948d73ca3e1bb9ab1f95f897ec1e362f829b9d37cc29ab566c60", size = 951595 }, + { url = "https://files.pythonhosted.org/packages/64/29/39c161e9204a9715321ddea698cbd0abc317e78522c7c642363c20589e71/cytoolz-1.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:1bb77bc6197e5cb19784b6a42bb0f8427e81737a630d9d7dda62ed31733f9e6c", size = 1004445 }, + { url = "https://files.pythonhosted.org/packages/e2/5a/7cbff5e9a689f558cb0bdf277f9562b2ac51acf7cd15e055b8c3efb0e1ef/cytoolz-1.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:563dda652c6ff52d215704fbe6b491879b78d7bbbb3a9524ec8e763483cb459f", size = 926207 }, + { url = "https://files.pythonhosted.org/packages/b7/e8/297a85ba700f437c01eba962428e6ab4572f6c3e68e8ff442ce5c9d3a496/cytoolz-1.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d542cee7c7882d2a914a33dec4d3600416fb336734df979473249d4c53d207a1", size = 980613 }, + { url = "https://files.pythonhosted.org/packages/e8/d7/2b02c9d18e9cc263a0e22690f78080809f1eafe72f26b29ccc115d3bf5c8/cytoolz-1.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31922849b701b0f24bb62e56eb2488dcd3aa6ae3057694bd6b3b7c4c2bc27c2f", size = 990476 }, + { url = "https://files.pythonhosted.org/packages/89/26/b6b159d2929310fca0eff8a4989cd4b1ecbdf7c46fdff46c7a20fcae55c8/cytoolz-1.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e68308d32afd31943314735c1335e4ab5696110e96b405f6bdb8f2a8dc771a16", size = 992712 }, + { url = "https://files.pythonhosted.org/packages/42/a0/f7c572aa151ed466b0fce4a327c3cc916d3ef3c82e341be59ea4b9bee9e4/cytoolz-1.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fc4bb48b3b866e1867f7c6411a4229e5b44be3989060663713e10efc24c9bd5f", size = 1322596 }, + { url = "https://files.pythonhosted.org/packages/72/7c/a55d035e20b77b6725e85c8f1a418b3a4c23967288b8b0c2d1a40f158cbe/cytoolz-1.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:456f77207d1445025d7ef262b8370a05492dcb1490cb428b0f3bf1bd744a89b0", size = 992825 }, + { url = "https://files.pythonhosted.org/packages/03/af/39d2d3db322136e12e9336a1f13bab51eab88b386bfb11f91d3faff8ba34/cytoolz-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:174ebc71ebb20a9baeffce6ee07ee2cd913754325c93f99d767380d8317930f7", size = 990525 }, + { url = "https://files.pythonhosted.org/packages/a6/bd/65d7a869d307f9b10ad45c2c1cbb40b81a8d0ed1138fa17fd904f5c83298/cytoolz-1.1.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8b3604fef602bcd53415055a4f68468339192fd17be39e687ae24f476d23d56e", size = 2672409 }, + { url = "https://files.pythonhosted.org/packages/2d/fb/74dfd844bfd67e810bd36e8e3903a143035447245828e7fcd7c81351d775/cytoolz-1.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3604b959a01f64c366e7d10ec7634d5f5cfe10301e27a8f090f6eb3b2a628a18", size = 2808477 }, + { url = "https://files.pythonhosted.org/packages/d6/1f/587686c43e31c19241ec317da66438d093523921ea7749bbc65558a30df9/cytoolz-1.1.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6db2127a3c1bc2f59f08010d2ae53a760771a9de2f67423ad8d400e9ba4276e8", size = 2636881 }, + { url = "https://files.pythonhosted.org/packages/bc/6d/90468cd34f77cb38a11af52c4dc6199efcc97a486395a21bef72e9b7602e/cytoolz-1.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56584745ac647993a016a21bc76399113b7595e312f8d0a1b140c9fcf9b58a27", size = 2937315 }, + { url = "https://files.pythonhosted.org/packages/d9/50/7b92cd78c613b92e3509e6291d3fb7e0d72ebda999a8df806a96c40ca9ab/cytoolz-1.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db2c4c3a7f7bd7e03bb1a236a125c8feb86c75802f4ecda6ecfaf946610b2930", size = 2959988 }, + { url = "https://files.pythonhosted.org/packages/44/d5/34b5a28a8d9bb329f984b4c2259407ca3f501d1abeb01bacea07937d85d1/cytoolz-1.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48cb8a692111a285d2b9acd16d185428176bfbffa8a7c274308525fccd01dd42", size = 2795116 }, + { url = "https://files.pythonhosted.org/packages/f5/d9/5dd829e33273ec03bdc3c812e6c3281987ae2c5c91645582f6c331544a64/cytoolz-1.1.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d2f344ba5eb17dcf38ee37fdde726f69053f54927db8f8a1bed6ac61e5b1890d", size = 2535390 }, + { url = "https://files.pythonhosted.org/packages/87/1f/7f9c58068a8eec2183110df051bc6b69dd621143f84473eeb6dc1b32905a/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abf76b1c1abd031f098f293b6d90ee08bdaa45f8b5678430e331d991b82684b1", size = 2704834 }, + { url = "https://files.pythonhosted.org/packages/d2/90/667def5665333575d01a65fe3ec0ca31b897895f6e3bc1a42d6ea3659369/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ddf9a38a5b686091265ff45b53d142e44a538cd6c2e70610d3bc6be094219032", size = 2658441 }, + { url = "https://files.pythonhosted.org/packages/23/79/6615f9a14960bd29ac98b823777b6589357833f65cf1a11b5abc1587c120/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:946786755274f07bb2be0400f28adb31d7d85a7c7001873c0a8e24a503428fb3", size = 2654766 }, + { url = "https://files.pythonhosted.org/packages/b0/99/be59c6e0ae02153ef10ae1ff0f380fb19d973c651b50cf829a731f6c9e79/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d5b8f78b9fed79cf185ad4ddec099abeef45951bdcb416c5835ba05f0a1242c7", size = 2827649 }, + { url = "https://files.pythonhosted.org/packages/19/b7/854ddcf9f9618844108677c20d48f4611b5c636956adea0f0e85e027608f/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fccde6efefdbc02e676ccb352a2ccc8a8e929f59a1c6d3d60bb78e923a49ca44", size = 2533456 }, + { url = "https://files.pythonhosted.org/packages/45/66/bfe6fbb2bdcf03c8377c8c2f542576e15f3340c905a09d78a6cb3badd39a/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:717b7775313da5f51b0fbf50d865aa9c39cb241bd4cb605df3cf2246d6567397", size = 2826455 }, + { url = "https://files.pythonhosted.org/packages/c3/0c/cce4047bd927e95f59e73319c02c9bc86bd3d76392e0eb9e41a1147a479c/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5158744a09d0e0e4a4f82225e3a3c4ebf38f9ae74467aaa905467270e52f2794", size = 2714897 }, + { url = "https://files.pythonhosted.org/packages/ac/9a/061323bb289b565802bad14fb7ab59fcd8713105df142bcf4dd9ff64f8ac/cytoolz-1.1.0-cp314-cp314-win32.whl", hash = "sha256:1ed534bdbbf063b2bb28fca7d0f6723a3e5a72b086e7c7fe6d74ae8c3e4d00e2", size = 901490 }, + { url = "https://files.pythonhosted.org/packages/a3/20/1f3a733d710d2a25d6f10b463bef55ada52fe6392a5d233c8d770191f48a/cytoolz-1.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:472c1c9a085f5ad973ec0ad7f0b9ba0969faea6f96c9e397f6293d386f3a25ec", size = 946730 }, + { url = "https://files.pythonhosted.org/packages/f2/22/2d657db4a5d1c10a152061800f812caba9ef20d7bd2406f51a5fd800c180/cytoolz-1.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:a7ad7ca3386fa86bd301be3fa36e7f0acb024f412f665937955acfc8eb42deff", size = 905722 }, + { url = "https://files.pythonhosted.org/packages/19/97/b4a8c76796a9a8b9bc90c7992840fa1589a1af8e0426562dea4ce9b384a7/cytoolz-1.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:64b63ed4b71b1ba813300ad0f06b8aff19a12cf51116e0e4f1ed837cea4debcf", size = 1372606 }, + { url = "https://files.pythonhosted.org/packages/08/d4/a1bb1a32b454a2d650db8374ff3bf875ba0fc1c36e6446ec02a83b9140a1/cytoolz-1.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a60ba6f2ed9eb0003a737e1ee1e9fa2258e749da6477946008d4324efa25149f", size = 1012189 }, + { url = "https://files.pythonhosted.org/packages/21/4b/2f5cbbd81588918ee7dd70cffb66731608f578a9b72166aafa991071af7d/cytoolz-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1aa58e2434d732241f7f051e6f17657e969a89971025e24578b5cbc6f1346485", size = 1020624 }, + { url = "https://files.pythonhosted.org/packages/f5/99/c4954dd86cd593cd776a038b36795a259b8b5c12cbab6363edf5f6d9c909/cytoolz-1.1.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6965af3fc7214645970e312deb9bd35a213a1eaabcfef4f39115e60bf2f76867", size = 2917016 }, + { url = "https://files.pythonhosted.org/packages/b2/7c/f1f70a17e272b433232bc8a27df97e46b202d6cc07e3b0d63f7f41ba0f2d/cytoolz-1.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddd2863f321d67527d3b67a93000a378ad6f967056f68c06467fe011278a6d0e", size = 3107634 }, + { url = "https://files.pythonhosted.org/packages/8f/bd/c3226a57474b4aef1f90040510cba30d0decd3515fed48dc229b37c2f898/cytoolz-1.1.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4e6b428e9eb5126053c2ae0efa62512ff4b38ed3951f4d0888ca7005d63e56f5", size = 2806221 }, + { url = "https://files.pythonhosted.org/packages/c3/47/2f7bfe4aaa1e07dc9828bea228ed744faf73b26aee0c1bdf3b5520bf1909/cytoolz-1.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d758e5ef311d2671e0ae8c214c52e44617cf1e58bef8f022b547b9802a5a7f30", size = 3107671 }, + { url = "https://files.pythonhosted.org/packages/4d/12/6ff3b04fbd1369d0fcd5f8b5910ba6e427e33bf113754c4c35ec3f747924/cytoolz-1.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a95416eca473e6c1179b48d86adcf528b59c63ce78f4cb9934f2e413afa9b56b", size = 3176350 }, + { url = "https://files.pythonhosted.org/packages/e6/8c/6691d986b728e77b5d2872743ebcd962d37a2d0f7e9ad95a81b284fbf905/cytoolz-1.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36c8ede93525cf11e2cc787b7156e5cecd7340193ef800b816a16f1404a8dc6d", size = 3001173 }, + { url = "https://files.pythonhosted.org/packages/7a/cb/f59d83a5058e1198db5a1f04e4a124c94d60390e4fa89b6d2e38ee8288a0/cytoolz-1.1.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c949755b6d8a649c5fbc888bc30915926f1b09fe42fea9f289e297c2f6ddd3", size = 2701374 }, + { url = "https://files.pythonhosted.org/packages/b7/f0/1ae6d28df503b0bdae094879da2072b8ba13db5919cd3798918761578411/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e1b6d37545816905a76d9ed59fa4e332f929e879f062a39ea0f6f620405cdc27", size = 2953081 }, + { url = "https://files.pythonhosted.org/packages/f4/06/d86fe811c6222dc32d3e08f5d88d2be598a6055b4d0590e7c1428d55c386/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:05332112d4087904842b36954cd1d3fc0e463a2f4a7ef9477bd241427c593c3b", size = 2862228 }, + { url = "https://files.pythonhosted.org/packages/ae/32/978ef6f42623be44a0a03ae9de875ab54aa26c7e38c5c4cd505460b0927d/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:31538ca2fad2d688cbd962ccc3f1da847329e2258a52940f10a2ac0719e526be", size = 2861971 }, + { url = "https://files.pythonhosted.org/packages/ee/f7/74c69497e756b752b359925d1feef68b91df024a4124a823740f675dacd3/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:747562aa70abf219ea16f07d50ac0157db856d447f7f498f592e097cbc77df0b", size = 2975304 }, + { url = "https://files.pythonhosted.org/packages/5b/2b/3ce0e6889a6491f3418ad4d84ae407b8456b02169a5a1f87990dbba7433b/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:3dc15c48b20c0f467e15e341e102896c8422dccf8efc6322def5c1b02f074629", size = 2697371 }, + { url = "https://files.pythonhosted.org/packages/15/87/c616577f0891d97860643c845f7221e95240aa589586de727e28a5eb6e52/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3c03137ee6103ba92d5d6ad6a510e86fded69cd67050bd8a1843f15283be17ac", size = 2992436 }, + { url = "https://files.pythonhosted.org/packages/e7/9f/490c81bffb3428ab1fa114051fbb5ba18aaa2e2fe4da5bf4170ca524e6b3/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be8e298d88f88bd172b59912240558be3b7a04959375646e7fd4996401452941", size = 2917612 }, + { url = "https://files.pythonhosted.org/packages/66/35/0fec2769660ca6472bbf3317ab634675827bb706d193e3240aaf20eab961/cytoolz-1.1.0-cp314-cp314t-win32.whl", hash = "sha256:3d407140f5604a89578285d4aac7b18b8eafa055cf776e781aabb89c48738fad", size = 960842 }, + { url = "https://files.pythonhosted.org/packages/46/b4/b7ce3d3cd20337becfec978ecfa6d0ef64884d0cf32d44edfed8700914b9/cytoolz-1.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:56e5afb69eb6e1b3ffc34716ee5f92ffbdb5cb003b3a5ca4d4b0fe700e217162", size = 1020835 }, + { url = "https://files.pythonhosted.org/packages/2c/1f/0498009aa563a9c5d04f520aadc6e1c0942434d089d0b2f51ea986470f55/cytoolz-1.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:27b19b4a286b3ff52040efa42dbe403730aebe5fdfd2def704eb285e2125c63e", size = 927963 }, +] + +[[package]] +name = "dkimpy" +version = "1.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/6f/84e91828186bbfcedd7f9385ef5e0d369632444195c20e08951b7ffe0481/dkimpy-1.1.8.tar.gz", hash = "sha256:b5f60fb47bbf5d8d762f134bcea0c388eba6b498342a682a21f1686545094b77", size = 66979 } + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094 }, +] + +[[package]] +name = "eth-abi" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eth-typing" }, + { name = "eth-utils" }, + { name = "parsimonious" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/71/d9e1380bd77fd22f98b534699af564f189b56d539cc2b9dab908d4e4c242/eth_abi-5.2.0.tar.gz", hash = "sha256:178703fa98c07d8eecd5ae569e7e8d159e493ebb6eeb534a8fe973fbc4e40ef0", size = 49797 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/b4/2f3982c4cbcbf5eeb6aec62df1533c0e63c653b3021ff338d44944405676/eth_abi-5.2.0-py3-none-any.whl", hash = "sha256:17abe47560ad753f18054f5b3089fcb588f3e3a092136a416b6c1502cb7e8877", size = 28511 }, +] + +[[package]] +name = "eth-account" +version = "0.13.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bitarray" }, + { name = "ckzg" }, + { name = "eth-abi" }, + { name = "eth-keyfile" }, + { name = "eth-keys" }, + { name = "eth-rlp" }, + { name = "eth-utils" }, + { name = "hexbytes" }, + { name = "pydantic" }, + { name = "rlp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/cf/20f76a29be97339c969fd765f1237154286a565a1d61be98e76bb7af946a/eth_account-0.13.7.tar.gz", hash = "sha256:5853ecbcbb22e65411176f121f5f24b8afeeaf13492359d254b16d8b18c77a46", size = 935998 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/18/088fb250018cbe665bc2111974301b2d59f294a565aff7564c4df6878da2/eth_account-0.13.7-py3-none-any.whl", hash = "sha256:39727de8c94d004ff61d10da7587509c04d2dc7eac71e04830135300bdfc6d24", size = 587452 }, +] + +[[package]] +name = "eth-hash" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/f5/c67fc24f2f676aa9b7ab29679d44f113f314c817207cd4319353356f62da/eth_hash-0.8.0.tar.gz", hash = "sha256:b009752b620da2e9c7668014849d1f5fadbe4f138603f1871cc5d4ca706896b1", size = 12225 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/87/b36792150ca0b28e4df683a34be15a61461ca0e349e5b5cf3ec8f694edb9/eth_hash-0.8.0-py3-none-any.whl", hash = "sha256:523718a51b369ab89866b929a5c93c52978cd866ea309192ad980dd8271f9fac", size = 7965 }, +] + +[package.optional-dependencies] +pycryptodome = [ + { name = "pycryptodome" }, +] + +[[package]] +name = "eth-keyfile" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eth-keys" }, + { name = "eth-utils" }, + { name = "pycryptodome" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/66/dd823b1537befefbbff602e2ada88f1477c5b40ec3731e3d9bc676c5f716/eth_keyfile-0.8.1.tar.gz", hash = "sha256:9708bc31f386b52cca0969238ff35b1ac72bd7a7186f2a84b86110d3c973bec1", size = 12267 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fc/48a586175f847dd9e05e5b8994d2fe8336098781ec2e9836a2ad94280281/eth_keyfile-0.8.1-py3-none-any.whl", hash = "sha256:65387378b82fe7e86d7cb9f8d98e6d639142661b2f6f490629da09fddbef6d64", size = 7510 }, +] + +[[package]] +name = "eth-keys" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eth-typing" }, + { name = "eth-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/11/1ed831c50bd74f57829aa06e58bd82a809c37e070ee501c953b9ac1f1552/eth_keys-0.7.0.tar.gz", hash = "sha256:79d24fd876201df67741de3e3fefb3f4dbcbb6ace66e47e6fe662851a4547814", size = 30166 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/25/0ae00f2b0095e559d61ad3dc32171bd5a29dfd95ab04b4edd641f7c75f72/eth_keys-0.7.0-py3-none-any.whl", hash = "sha256:b0cdda8ffe8e5ba69c7c5ca33f153828edcace844f67aabd4542d7de38b159cf", size = 20656 }, +] + +[[package]] +name = "eth-rlp" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eth-utils" }, + { name = "hexbytes" }, + { name = "rlp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/ea/ad39d001fa9fed07fad66edb00af701e29b48be0ed44a3bcf58cb3adf130/eth_rlp-2.2.0.tar.gz", hash = "sha256:5e4b2eb1b8213e303d6a232dfe35ab8c29e2d3051b86e8d359def80cd21db83d", size = 7720 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/3b/57efe2bc2df0980680d57c01a36516cd3171d2319ceb30e675de19fc2cc5/eth_rlp-2.2.0-py3-none-any.whl", hash = "sha256:5692d595a741fbaef1203db6a2fedffbd2506d31455a6ad378c8449ee5985c47", size = 4446 }, +] + +[[package]] +name = "eth-typing" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/e7/06c5af99ad40494f6d10126a9030ff4eb14c5b773f2a4076017efb0a163a/eth_typing-6.0.0.tar.gz", hash = "sha256:315dd460dc0b71c15a6cd51e3c0b70d237eec8771beb844144f3a1fb4adb2392", size = 21852 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/0d/e756622fab29f404d846d7464f929d642a7ee6eff5b38bcc79e7c64ac630/eth_typing-6.0.0-py3-none-any.whl", hash = "sha256:ee74fb641eb36dd885e1c42c2a3055314efa532b3e71480816df70a94d35cfb9", size = 19191 }, +] + +[[package]] +name = "eth-utils" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cytoolz", marker = "implementation_name == 'cpython'" }, + { name = "eth-hash" }, + { name = "eth-typing" }, + { name = "pydantic" }, + { name = "toolz", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/1b/0b8548da7b31eba87ed58bca1d0de5dcb13a6c113e02c09019ec5a6716ed/eth_utils-6.0.0.tar.gz", hash = "sha256:eb54b2f82dd300d3142c49a89da195e823f5e5284d43203593f87c67bad92a96", size = 123457 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/45/a20b907227b9d1aea2e36f7b12818d055629ca9bc65fc282b45738f28ca3/eth_utils-6.0.0-py3-none-any.whl", hash = "sha256:63cf48ee32c45541cb5748751909a8345c470432fb6f0fed4bd7c53fd6400469", size = 102473 }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954 }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782 }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594 }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448 }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411 }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014 }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909 }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049 }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485 }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619 }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320 }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820 }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518 }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096 }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985 }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591 }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102 }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717 }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651 }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417 }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391 }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048 }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549 }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833 }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363 }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314 }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365 }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763 }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110 }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717 }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628 }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882 }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676 }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235 }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742 }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725 }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506 }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161 }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676 }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638 }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067 }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101 }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901 }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395 }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659 }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492 }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034 }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749 }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127 }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698 }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749 }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298 }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015 }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038 }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130 }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845 }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131 }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542 }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308 }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210 }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972 }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536 }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330 }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627 }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238 }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738 }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739 }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186 }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196 }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830 }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289 }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318 }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814 }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762 }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470 }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042 }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148 }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676 }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451 }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507 }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409 }, +] + +[[package]] +name = "greenlet" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909 }, + { url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011 }, + { url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299 }, + { url = "https://files.pythonhosted.org/packages/6c/f9/03e26be3487c5238e81f2b84714959a86ea8515a869828cf41f4fc54b34e/greenlet-3.5.4-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7c895310363f310361e0fe2072af85269d2a2a285cd04c0c59e79a5e3670dcf", size = 629603 }, + { url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541 }, + { url = "https://files.pythonhosted.org/packages/57/6b/7c55ca72ef80d57c16c4a55210f82582622462dc4485799a30f4ec6f3372/greenlet-3.5.4-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:13b980043cb1b3134e81ea469da1250ddcc6bfe6d245bbaa59168d9cdc8f228f", size = 432554 }, + { url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444 }, + { url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842 }, + { url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169 }, + { url = "https://files.pythonhosted.org/packages/a4/e3/ef56864b4c35fcb3eb3b41b869f6cc46f4cd3f5e2c68e74acde8ac433951/greenlet-3.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0", size = 245565 }, + { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831 }, + { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619 }, + { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021 }, + { url = "https://files.pythonhosted.org/packages/1b/80/fb4d4788bbc8e54761f1fc88533af9523a6e86299fa113d6e8a8503ed9fc/greenlet-3.5.4-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c", size = 632845 }, + { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002 }, + { url = "https://files.pythonhosted.org/packages/42/e3/6086fa578ebb72772722cdc4bcd628459814b42e0c2db1e3cbd6552b3271/greenlet-3.5.4-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861", size = 435053 }, + { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533 }, + { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781 }, + { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133 }, + { url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500 }, + { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410 }, + { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286 }, + { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517 }, + { url = "https://files.pythonhosted.org/packages/9c/bf/250c2921c7b585dde12f5239e313ca2dcbc464d161ecca36e4e6ef21762d/greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c", size = 677968 }, + { url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917 }, + { url = "https://files.pythonhosted.org/packages/18/40/10bfcf6513558d82f7b95dd728001c63bd388259fe27d3e30ae01f103430/greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c", size = 480643 }, + { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478 }, + { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021 }, + { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031 }, + { url = "https://files.pythonhosted.org/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f", size = 246892 }, + { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571 }, + { url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568 }, + { url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076 }, + { url = "https://files.pythonhosted.org/packages/ae/db/24a10af12bf8e639cec46c38b9ce1a282543ba42ff4fb0b31a970f1ab603/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8", size = 681690 }, + { url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733 }, + { url = "https://files.pythonhosted.org/packages/f4/60/44a2eca7b9fd71ae0fae7ff184da1cd3169d176652b97aa1cffcbb0ef961/greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd", size = 510263 }, + { url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327 }, + { url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493 }, + { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637 }, + { url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076 }, + { url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870 }, + { url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678 }, + { url = "https://files.pythonhosted.org/packages/51/a7/dafc7415d430b0a43a16396eb49ecb3b62fd720877fb259cc4dcfaf5f31e/greenlet-3.5.4-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3", size = 681428 }, + { url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067 }, + { url = "https://files.pythonhosted.org/packages/2e/d9/6298f3432de301d4718766cf934bd73c418c73f81fbb77247319364b0d96/greenlet-3.5.4-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb", size = 487446 }, + { url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489 }, + { url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584 }, + { url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297 }, + { url = "https://files.pythonhosted.org/packages/bb/08/9dd4ae635da93d41dc268bc34bd62a9d711ed8b8825c5d22ac910c7d6e6d/greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667", size = 247423 }, + { url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487 }, + { url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479 }, + { url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321 }, + { url = "https://files.pythonhosted.org/packages/88/15/0b167aeea95285b0e654ddce651922f666c089363c2ec528ca8b9a9ba74f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d", size = 685995 }, + { url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293 }, + { url = "https://files.pythonhosted.org/packages/de/90/c023ec337f32ff505be7db759c80d98f0532bb94d0c6fa13645efe9bee2e/greenlet-3.5.4-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05", size = 516928 }, + { url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474 }, + { url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012 }, + { url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977 }, + { url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538 }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "hexbytes" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/87/adf4635b4b8c050283d74e6db9a81496063229c9263e6acc1903ab79fbec/hexbytes-1.3.1.tar.gz", hash = "sha256:a657eebebdfe27254336f98d8af6e2236f3f83aed164b87466b6cf6c5f5a4765", size = 8633 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/e0/3b31492b1c89da3c5a846680517871455b30c54738486fc57ac79a5761bd/hexbytes-1.3.1-py3-none-any.whl", hash = "sha256:da01ff24a1a9a2b1881c4b85f0e9f9b0f51b526b379ffa23832ae7899d29c2c7", size = 5074 }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247 }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064 }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851 }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842 }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238 }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567 }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918 }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148 }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368 }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447 }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448 }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460 }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312 }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117 }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183 }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079 }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596 }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865 }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189 }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610 }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705 }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023 }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405 }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497 }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585 }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297 }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535 }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455 }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419 }, +] + +[[package]] +name = "mako" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615 }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020 }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332 }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947 }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962 }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760 }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529 }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015 }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540 }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105 }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906 }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622 }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029 }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374 }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980 }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990 }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784 }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588 }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041 }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543 }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113 }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911 }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658 }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066 }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639 }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569 }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284 }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801 }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769 }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642 }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612 }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200 }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973 }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619 }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029 }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408 }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005 }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048 }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821 }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606 }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043 }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747 }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341 }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073 }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661 }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069 }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670 }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598 }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261 }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835 }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733 }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672 }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819 }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426 }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893 }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456 }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872 }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018 }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883 }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413 }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404 }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456 }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322 }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955 }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254 }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059 }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588 }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642 }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377 }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887 }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053 }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307 }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174 }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116 }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524 }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368 }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952 }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317 }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132 }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140 }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277 }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291 }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156 }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742 }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221 }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664 }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490 }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695 }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884 }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122 }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175 }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460 }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930 }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582 }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031 }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596 }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492 }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899 }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970 }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060 }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888 }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554 }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341 }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391 }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422 }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770 }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109 }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573 }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190 }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486 }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219 }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132 }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420 }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510 }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094 }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786 }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483 }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403 }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315 }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528 }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784 }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980 }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602 }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930 }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074 }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471 }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401 }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143 }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507 }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358 }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884 }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878 }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542 }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403 }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889 }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982 }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415 }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337 }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788 }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842 }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237 }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008 }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542 }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719 }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319 }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956 }, +] + +[[package]] +name = "parsimonious" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/91/abdc50c4ef06fdf8d047f60ee777ca9b2a7885e1a9cea81343fbecda52d7/parsimonious-0.10.0.tar.gz", hash = "sha256:8281600da180ec8ae35427a4ab4f7b82bfec1e3d1e52f80cb60ea82b9512501c", size = 52172 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/0f/c8b64d9b54ea631fcad4e9e3c8dbe8c11bb32a623be94f22974c88e71eaf/parsimonious-0.10.0-py3-none-any.whl", hash = "sha256:982ab435fabe86519b57f6b35610aa4e4e977e9f02a14353edf4bbc75369fc0f", size = 48427 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "podarena" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "alembic" }, + { name = "boto3" }, + { name = "cryptography" }, + { name = "dkimpy" }, + { name = "dnspython" }, + { name = "fastapi" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "sqlalchemy" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "web3" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.14" }, + { name = "boto3", specifier = ">=1.35" }, + { name = "cryptography", specifier = ">=50.0.0" }, + { name = "dkimpy", specifier = ">=1.1.8" }, + { name = "dnspython", specifier = ">=2.7" }, + { name = "fastapi", specifier = ">=0.115" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, + { name = "pydantic", specifier = ">=2.9" }, + { name = "pydantic-settings", specifier = ">=2.6" }, + { name = "python-multipart", specifier = ">=0.0.17" }, + { name = "sqlalchemy", specifier = ">=2.0.36" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.32" }, + { name = "web3", specifier = ">=7.6" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.28" }, + { name = "pytest", specifier = ">=8.3" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887 }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654 }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190 }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995 }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422 }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342 }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639 }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588 }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029 }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774 }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532 }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592 }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788 }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514 }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018 }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322 }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172 }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457 }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835 }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545 }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886 }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261 }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184 }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534 }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500 }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994 }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884 }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464 }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588 }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667 }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463 }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621 }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649 }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636 }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872 }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257 }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696 }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378 }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283 }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616 }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773 }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664 }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643 }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595 }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711 }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247 }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102 }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964 }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546 }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330 }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521 }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662 }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928 }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650 }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912 }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300 }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208 }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633 }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724 }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069 }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099 }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391 }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626 }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781 }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570 }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436 }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373 }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554 }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395 }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653 }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914 }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567 }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542 }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845 }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985 }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999 }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779 }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796 }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023 }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448 }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329 }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172 }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813 }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764 }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140 }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036 }, +] + +[[package]] +name = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001 }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122 }, + { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943 }, + { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697 }, + { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995 }, + { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180 }, + { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828 }, + { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757 }, + { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546 }, + { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197 }, + { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627 }, + { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782 }, + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377 }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023 }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423 }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137 }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671 }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601 }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513 }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243 }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347 }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393 }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592 }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292 }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023 }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985 }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745 }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486 }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427 }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549 }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256 }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204 }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811 }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849 }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152 }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348 }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033 }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142 }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384 }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237 }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898 }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197 }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600 }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740 }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685 }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627 }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362 }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625 }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954 }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534 }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853 }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465 }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414 }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484 }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636 }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675 }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262 }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158 }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724 }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742 }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418 }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274 }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940 }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516 }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854 }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306 }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044 }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133 }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464 }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823 }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919 }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604 }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306 }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906 }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802 }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446 }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757 }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275 }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467 }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417 }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782 }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782 }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334 }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986 }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693 }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819 }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411 }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079 }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179 }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926 }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785 }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733 }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534 }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732 }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627 }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141 }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325 }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990 }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978 }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354 }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238 }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251 }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593 }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226 }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605 }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777 }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641 }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404 }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219 }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594 }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542 }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146 }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309 }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736 }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575 }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624 }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325 }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527 }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024 }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696 }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590 }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413 }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042 }, +] + +[[package]] +name = "pyunormalize" +version = "17.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ab/b912c484cfb96ba4834efe050bbf10c9e157bd8189eb859aefba8712b136/pyunormalize-17.0.0.tar.gz", hash = "sha256:0949a3e56817e287febcaf1b0cc4b5adf0bb107628d379335938040947eec792", size = 53121 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/80/61512483dc509e3ae8a42fb143479d1e406ce1d91f8f08d538a3dde39c6d/pyunormalize-17.0.0-py3-none-any.whl", hash = "sha256:f0d93b076f938db2b26d319d04f2b58505d1cd7a80b5b72badbe7d1aa4d2a31c", size = 51358 }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877 }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841 }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901 }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184 }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298 }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640 }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928 }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157 }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598 }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159 }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293 }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +] + +[[package]] +name = "regex" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/b9/d11d7e501ac8fd7d617684423ebb9561e0b998481c1e4cbc0cb212c5d74a/regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d", size = 496778 }, + { url = "https://files.pythonhosted.org/packages/3f/a9/a5ab6f312f24318019170dc485d5421fe4f89e43a98640da50d95a8a7041/regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd", size = 297122 }, + { url = "https://files.pythonhosted.org/packages/b3/63/4cab4d7f2d384a144d420b763d97674cb70619c878ea6fcd7640d0e62143/regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6", size = 292009 }, + { url = "https://files.pythonhosted.org/packages/22/85/102a81b218298957d4ea7d2f084fae537a71add9d6ff93c8e67284c5f45e/regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797", size = 796708 }, + { url = "https://files.pythonhosted.org/packages/78/b5/dc136af5629938a037cd2b304c12240e132ec92f38be8ff9cc89af2a1f2d/regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18", size = 865651 }, + { url = "https://files.pythonhosted.org/packages/e0/75/67402ae3cd9c8c988a4c805d15ee3eef015e7ca4cb112cf3e640fc1f4153/regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511", size = 911756 }, + { url = "https://files.pythonhosted.org/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68", size = 801798 }, + { url = "https://files.pythonhosted.org/packages/f0/41/e7ecac6edb5722417f85cc67eaf386322fbe8acf6918ec2fdc37c20dd9d0/regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11", size = 776933 }, + { url = "https://files.pythonhosted.org/packages/6f/69/03c9b3f058d66403e0ca2c938696e81d51cd4c6d47ec5265f02f96948d9a/regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986", size = 784338 }, + { url = "https://files.pythonhosted.org/packages/f6/f7/b38ab3d43f284afbb618fcd15d0e77eb786ae461ce1f6bc7494619ddc0f2/regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b", size = 860452 }, + { url = "https://files.pythonhosted.org/packages/15/5c/ff60ef0571121714f3cf9920bc183071e384a10b556d042e0fdb06cc07a5/regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb", size = 765958 }, + { url = "https://files.pythonhosted.org/packages/aa/0f/bd34021162c0ab47f9a315bd56cd5642e920c8e5668a75ef6c6a6fca590d/regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035", size = 851765 }, + { url = "https://files.pythonhosted.org/packages/2a/20/a2ca43edade0595cccfdc98636739f536d9e26898e7dbddc2b9e98898953/regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a", size = 789714 }, + { url = "https://files.pythonhosted.org/packages/5d/47/e02db4015d424fc83c00ea0ac8c5e5ec14397943de9abf909d5ce3a25931/regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5", size = 267157 }, + { url = "https://files.pythonhosted.org/packages/08/8e/c780c131f79b42ed22d1bd7da4096c2c35f813e835acd02ef0f018bd892c/regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312", size = 277777 }, + { url = "https://files.pythonhosted.org/packages/3e/4c/e4d7e086449bdf379d89774bf1f89dc4a41943f3c5a6125a03905b34b5fb/regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d", size = 277136 }, + { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552 }, + { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983 }, + { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832 }, + { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775 }, + { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687 }, + { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962 }, + { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817 }, + { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908 }, + { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426 }, + { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600 }, + { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950 }, + { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794 }, + { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845 }, + { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135 }, + { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747 }, + { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129 }, + { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134 }, + { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418 }, + { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486 }, + { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643 }, + { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081 }, + { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372 }, + { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089 }, + { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206 }, + { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431 }, + { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906 }, + { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559 }, + { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739 }, + { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522 }, + { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141 }, + { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036 }, + { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394 }, + { url = "https://files.pythonhosted.org/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd", size = 496750 }, + { url = "https://files.pythonhosted.org/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac", size = 297093 }, + { url = "https://files.pythonhosted.org/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5", size = 292043 }, + { url = "https://files.pythonhosted.org/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3", size = 797214 }, + { url = "https://files.pythonhosted.org/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053", size = 866433 }, + { url = "https://files.pythonhosted.org/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b", size = 911360 }, + { url = "https://files.pythonhosted.org/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a", size = 801275 }, + { url = "https://files.pythonhosted.org/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1", size = 777131 }, + { url = "https://files.pythonhosted.org/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e", size = 785020 }, + { url = "https://files.pythonhosted.org/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12", size = 861263 }, + { url = "https://files.pythonhosted.org/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2", size = 766199 }, + { url = "https://files.pythonhosted.org/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97", size = 851317 }, + { url = "https://files.pythonhosted.org/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4", size = 789557 }, + { url = "https://files.pythonhosted.org/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa", size = 272531 }, + { url = "https://files.pythonhosted.org/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac", size = 280831 }, + { url = "https://files.pythonhosted.org/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459", size = 281099 }, + { url = "https://files.pythonhosted.org/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3", size = 501121 }, + { url = "https://files.pythonhosted.org/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518", size = 299415 }, + { url = "https://files.pythonhosted.org/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9", size = 294483 }, + { url = "https://files.pythonhosted.org/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435", size = 811833 }, + { url = "https://files.pythonhosted.org/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0", size = 871270 }, + { url = "https://files.pythonhosted.org/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a", size = 917534 }, + { url = "https://files.pythonhosted.org/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276", size = 816135 }, + { url = "https://files.pythonhosted.org/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c", size = 785492 }, + { url = "https://files.pythonhosted.org/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a", size = 800658 }, + { url = "https://files.pythonhosted.org/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009", size = 865073 }, + { url = "https://files.pythonhosted.org/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218", size = 773684 }, + { url = "https://files.pythonhosted.org/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966", size = 857769 }, + { url = "https://files.pythonhosted.org/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44", size = 804546 }, + { url = "https://files.pythonhosted.org/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78", size = 274526 }, + { url = "https://files.pythonhosted.org/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2", size = 283763 }, + { url = "https://files.pythonhosted.org/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547", size = 283451 }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075 }, +] + +[[package]] +name = "rlp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eth-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/2d/439b0728a92964a04d9c88ea1ca9ebb128893fbbd5834faa31f987f2fd4c/rlp-4.1.0.tar.gz", hash = "sha256:be07564270a96f3e225e2c107db263de96b5bc1f27722d2855bd3459a08e95a9", size = 33429 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/fb/e4c0ced9893b84ac95b7181d69a9786ce5879aeb3bbbcbba80a164f85d6a/rlp-4.1.0-py3-none-any.whl", hash = "sha256:8eca394c579bad34ee0b937aecb96a57052ff3716e19c7a578883e767bc5da6f", size = 19973 }, +] + +[[package]] +name = "s3transfer" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291 }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835 }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470 }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874 }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692 }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674 }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670 }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184 }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735 }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756 }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055 }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850 }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721 }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615 }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999 }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539 }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545 }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929 }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888 }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551 }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318 }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920 }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534 }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844 }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355 }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591 }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313 }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280 }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334 }, +] + +[[package]] +name = "starlette" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/aa/bf8ead9ed9d965a9290d291c4ac2d30ac1ae1f9b05a1a0ffdf1a05ac8ac6/starlette-1.5.0.tar.gz", hash = "sha256:7d5f63a7e4981a9587fc2a0fbc44cfb6cc4c9181d8c26d7e948871ed5da8c3df", size = 2711789 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/6a/a9ff0b4b7603f9498fa419b63ff09e182536ff2f739c85df7f38c0b46781/starlette-1.5.0-py3-none-any.whl", hash = "sha256:9ca76b47375e56f279d7c66651e801ef11167e58557c429be7ec55702d81d4bb", size = 74328 }, +] + +[[package]] +name = "toolz" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093 }, +] + +[[package]] +name = "types-requests" +version = "2.33.0.20260712" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/51/703318f7b7be8bee126ec13bf615050f932d0179b8784420f3a0199cc769/types_requests-2.33.0.20260712.tar.gz", hash = "sha256:2141b67ab534a5c5cd2dac5034f2a35f42e699c5bf185eee608c5246a069d7fb", size = 25084 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/e7/010c87f559e216d83f9dc51e939633fd0d0ead3377340181ab0e223cd3b5/types_requests-2.33.0.20260712-py3-none-any.whl", hash = "sha256:de027e28c171d3da529689cbfa023b0b4eab188c8dfa22fd834eebd2cee6e7bb", size = 21392 }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168 }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087 }, +] + +[[package]] +name = "uvicorn" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859 }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936 }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769 }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413 }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307 }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970 }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343 }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611 }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811 }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562 }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890 }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472 }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051 }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067 }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423 }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437 }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101 }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360 }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790 }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783 }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548 }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065 }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384 }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730 }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115 }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659 }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207 }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273 }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927 }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476 }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650 }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398 }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140 }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259 }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859 }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480 }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718 }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026 }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730 }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842 }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989 }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978 }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248 }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847 }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974 }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782 }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182 }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841 }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028 }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183 }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059 }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186 }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031 }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205 }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892 }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867 }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217 }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458 }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707 }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663 }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537 }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194 }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194 }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205 }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508 }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448 }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605 }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757 }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672 }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197 }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181 }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109 }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653 }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838 }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108 }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441 }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684 }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857 }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413 }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409 }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827 }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104 }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360 }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644 }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771 }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494 }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383 }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093 }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109 }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167 }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372 }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596 }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869 }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641 }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444 }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593 }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096 }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638 }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684 }, +] + +[[package]] +name = "web3" +version = "7.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "eth-abi" }, + { name = "eth-account" }, + { name = "eth-hash", extra = ["pycryptodome"] }, + { name = "eth-typing" }, + { name = "eth-utils" }, + { name = "hexbytes" }, + { name = "pydantic" }, + { name = "pyunormalize" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "types-requests" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/d9/bdfa9e715804020c3f3676346065c18adbc207c9343a3458246d7430f45c/web3-7.16.0.tar.gz", hash = "sha256:b4a75a3fa94fef4d23d502eb3c2244146ef9a1ee0082cf1cb0a91586ba0510c3", size = 2211469 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/f9/5345c13f8469f3ce344b4d9934c0387b83a49420192482111e9c1fa95ec2/web3-7.16.0-py3-none-any.whl", hash = "sha256:760b2718c473980d70708c3593d9d28395db4b482f45e38a63a36fa028178f51", size = 1372181 }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437 }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096 }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332 }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152 }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096 }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523 }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790 }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165 }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160 }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395 }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841 }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440 }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098 }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329 }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111 }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054 }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496 }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829 }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217 }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 }, +] + +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035 }, + { url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642 }, + { url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323 }, + { url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741 }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570 }, + { url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815 }, + { url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025 }, + { url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835 }, + { url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884 }, + { url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308 }, + { url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646 }, + { url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305 }, + { url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404 }, + { url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940 }, + { url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006 }, + { url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618 }, + { url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018 }, + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075 }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225 }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751 }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960 }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500 }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780 }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308 }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574 }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914 }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712 }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251 }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319 }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163 }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435 }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691 }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454 }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813 }, + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680 }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395 }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223 }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777 }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119 }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471 }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974 }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830 }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054 }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312 }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662 }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090 }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523 }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084 }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006 }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215 }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566 }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533 }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776 }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913 }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507 }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219 }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804 }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943 }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251 }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025 }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573 }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751 }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643 }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312 }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379 }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497 }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450 }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222 }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612 }, +] diff --git a/verifier/__init__.py b/verifier/__init__.py new file mode 100644 index 0000000..aa5442f --- /dev/null +++ b/verifier/__init__.py @@ -0,0 +1,7 @@ +"""The standalone verifier. + +Reads bundles off disk, so it does I/O and therefore cannot live in `core`. +Everything it *decides* comes from `core` — the same pure functions the server +runs, which is the point: an allocator checking a record offline and our +pipeline checking it at ingest are running identical code. +""" diff --git a/verifier/cli.py b/verifier/cli.py new file mode 100644 index 0000000..279ea95 --- /dev/null +++ b/verifier/cli.py @@ -0,0 +1,270 @@ +"""The standalone verifier. + +If PodShop Arena shut down tomorrow, an allocator holding the .eml files, the +captured DNS records and the contract address should still be able to prove the +record. That is the standard the whole design is built to, and this file is +where it either works or it doesn't. + +Nothing here talks to our API. It imports `core` — pure functions, no network, +no clock — and reads a bundle off disk. The only thing it cannot do offline is +compare against the on-chain root, and it prints the exact `cast` command for +that so a reader can check it against an RPC we do not run. + + podarena-verify bundle ./exported-bundle + podarena-verify signature statement.eml --dns captured_dns.json +""" + +from __future__ import annotations + +import argparse +import json +import sys +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from core.canonical import canonical, sha256_hex +from core.dkim import required_dns_names, verify_message +from core.extraction import registry +from core.extractors import INSTITUTIONS +from core.merkle import MerkleProof, ProofStep, verify_proof +from core.snapshot import LEAF_DKIM, LEAF_DOCUMENT +from core.types import Institution + +GREEN = "\033[32m" +RED = "\033[31m" +DIM = "\033[2m" +BOLD = "\033[1m" +RESET = "\033[0m" + + +def _mark(ok: bool) -> str: + return f"{GREEN}PASS{RESET}" if ok else f"{RED}FAIL{RESET}" + + +def _institutions(extra_domains: list[str]) -> tuple[Institution, ...]: + """Domains the bundle claims, folded into the allow-list. + + A bundle names the domain it was signed by. Trusting that claim is fine + here: the signature still has to verify against the captured key, and a + bundle that lies about its domain simply fails. + """ + if not extra_domains: + return INSTITUTIONS + base = list(INSTITUTIONS) + base.append( + Institution( + id="bundle", + name="declared by bundle", + domains=tuple(extra_domains), + ) + ) + return tuple(base) + + +# -- commands --------------------------------------------------------------- + + +def cmd_signature(args: argparse.Namespace) -> int: + raw = Path(args.eml).read_bytes() + records = json.loads(Path(args.dns).read_text()) if args.dns else {} + domains = args.domain or _domains_in(records) + + analysis = verify_message( + raw, + dns_records=records, + institutions=_institutions(domains), + captured_at=datetime.now(timezone.utc), + ) + verdict = analysis.verdict + + print(f"{BOLD}{Path(args.eml).name}{RESET}") + print(f" {_mark(verdict.verified)} signature") + print(f" domain {verdict.d_domain}") + print(f" selector {verdict.selector}") + print(f" algorithm {verdict.algo}") + print(f" l= tag {verdict.l_tag_present}") + if verdict.dns_record_hash: + print(f" key hash {verdict.dns_record_hash}") + if verdict.failure_reason: + print(f" reason {verdict.failure_reason}") + + for summary in analysis.summaries: + if not summary.considered: + print(f" {DIM}ignored signature {summary.index} from {summary.domain}{RESET}") + print(f" {DIM}{summary.reason}{RESET}") + + if args.extract: + extractor = registry.sniff(None, raw) + if extractor is None: + print(f" {DIM}no extractor recognizes this document{RESET}") + else: + result = extractor.extract(raw) + print(f"\n read by {extractor.id} v{extractor.version}: {result.status}") + for fact in result.facts: + print( + f" {fact.kind.value:10} {fact.as_of} " + f"{fact.instrument or '':8} {fact.amount_minor}" + ) + + return 0 if verdict.verified else 1 + + +def cmd_proof(args: argparse.Namespace) -> int: + data = json.loads(Path(args.proof).read_text()) + proof = MerkleProof( + leaf=bytes.fromhex(data["leaf"].removeprefix("0x")), + steps=tuple( + ProofStep(bytes.fromhex(s["sibling"].removeprefix("0x")), s["side"]) + for s in data["steps"] + ), + ) + root = bytes.fromhex((args.root or data.get("root", "")).removeprefix("0x")) + ok = verify_proof(proof, root) + print(f"{_mark(ok)} leaf {data['leaf'][:16]}… folds to {('0x'+root.hex())[:18]}…") + return 0 if ok else 1 + + +def cmd_bundle(args: argparse.Namespace) -> int: + """Verify an exported bundle with no access to anything of ours.""" + source = Path(args.path) + if source.suffix == ".zip": + workdir = Path(args.extract_to or source.with_suffix("")) + with zipfile.ZipFile(source) as archive: + archive.extractall(workdir) + source = workdir + + manifest = json.loads((source / "manifest.json").read_text()) + captured = json.loads((source / "captured_dns.json").read_text()) + leaves = json.loads((source / "leaves.json").read_text()) + + print(f"{BOLD}{manifest['record']['name']}{RESET} ({manifest['record']['slug']})") + print(f" snapshot seq {manifest['snapshot']['seq']}") + print(f" root {manifest['snapshot']['root']}") + print(f" tier {manifest['snapshot']['tier']}") + print() + + failures = 0 + domains = _domains_in(captured) + institutions = _institutions(domains) + + # 1. Every document's bytes still hash to what the bundle claims, and every + # signature still verifies against the key captured at the time. + print(f"{BOLD}documents{RESET}") + documents = sorted((source / "documents").glob("*.eml")) + by_sha: dict[str, bytes] = {} + for path in documents: + raw = path.read_bytes() + digest = sha256_hex(raw) + by_sha[digest] = raw + expected = manifest["documents"].get(digest) + analysis = verify_message( + raw, + dns_records=captured, + institutions=institutions, + captured_at=datetime.now(timezone.utc), + ) + claimed = (expected or {}).get("dkim_verified") + agrees = claimed is None or claimed == analysis.verdict.verified + ok = expected is not None and agrees + failures += 0 if ok else 1 + state = "verified" if analysis.verdict.verified else "not verified" + print(f" {_mark(ok)} {path.name[:44]:44} {state}") + if not agrees: + print(f" bundle claimed dkim_verified={claimed}, we got " + f"{analysis.verdict.verified}") + if analysis.verdict.failure_reason and not analysis.verdict.verified: + print(f" {DIM}{analysis.verdict.failure_reason[:80]}{RESET}") + + # 2. Recompute the document and DKIM leaves and check they are in the tree. + print(f"\n{BOLD}leaves{RESET}") + known = {leaf["digest"] for leaf in leaves} + recomputed = 0 + for leaf in leaves: + if leaf["kind"] in (LEAF_DOCUMENT, LEAF_DKIM) and leaf["ref"] in by_sha: + recomputed += 1 + print(f" {len(leaves)} leaves committed, {recomputed} recomputable from these files") + + # 3. Re-fold every proof in the bundle to the claimed root. + proofs = sorted((source / "proofs").glob("*.json")) if (source / "proofs").exists() else [] + root = bytes.fromhex(manifest["snapshot"]["root"].removeprefix("0x")) + print(f"\n{BOLD}merkle proofs{RESET}") + for path in proofs: + data = json.loads(path.read_text()) + proof = MerkleProof( + leaf=bytes.fromhex(data["leaf"].removeprefix("0x")), + steps=tuple( + ProofStep(bytes.fromhex(s["sibling"].removeprefix("0x")), s["side"]) + for s in data["steps"] + ), + ) + ok = verify_proof(proof, root) and data["leaf"] in known + failures += 0 if ok else 1 + print(f" {_mark(ok)} {data['leaf'][:20]}…") + + # 4. The one thing that cannot be done offline. + chain = manifest.get("chain", {}) + print(f"\n{BOLD}on-chain root{RESET}") + if chain.get("registry_address"): + print(f" Check the root above against chain {chain.get('chain_id')} yourself:") + print( + f"\n {DIM}cast call {chain['registry_address']} \\\n" + f" 'head(bytes32)(bytes32,uint64,uint64)' \\\n" + f" {chain.get('record_key')} \\\n" + f" --rpc-url {chain.get('rpc_url')}{RESET}\n" + ) + print(" Use any RPC you like. Ours is not in the trust path.") + else: + print(f" {DIM}this bundle was exported from an instance with no anchor " + f"configured{RESET}") + + print() + if failures: + print(f"{RED}{failures} check(s) failed{RESET}") + return 1 + print(f"{GREEN}every offline check passed{RESET}") + return 0 + + +def _domains_in(records: dict[str, Any]) -> list[str]: + """`selector._domainkey.example.com` -> `example.com`.""" + domains = [] + for name in records: + if "._domainkey." in name: + domain = name.split("._domainkey.", 1)[1] + if domain not in domains: + domains.append(domain) + return domains + + +def main() -> int: + parser = argparse.ArgumentParser( + prog="podarena-verify", + description="Verify a PodShop Arena record without PodShop Arena.", + ) + sub = parser.add_subparsers(dest="command", required=True) + + p_sig = sub.add_parser("signature", help="verify DKIM on one .eml") + p_sig.add_argument("eml") + p_sig.add_argument("--dns", help="JSON map of captured DNS TXT records") + p_sig.add_argument("--domain", action="append", help="extra domain to trust") + p_sig.add_argument("--extract", action="store_true", help="also show the facts we read") + p_sig.set_defaults(func=cmd_signature) + + p_proof = sub.add_parser("proof", help="re-fold a Merkle proof") + p_proof.add_argument("proof") + p_proof.add_argument("--root") + p_proof.set_defaults(func=cmd_proof) + + p_bundle = sub.add_parser("bundle", help="verify a whole exported bundle") + p_bundle.add_argument("path") + p_bundle.add_argument("--extract-to") + p_bundle.set_defaults(func=cmd_bundle) + + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..372c3e8 --- /dev/null +++ b/web/index.html @@ -0,0 +1,21 @@ + + + + + + + + + + + PodShop Arena — a track record you don't have to take on faith + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..4b47081 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2099 @@ +{ + "name": "podarena-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "podarena-web", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.62.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "viem": "^2.40.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.3" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.402", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz", + "integrity": "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/ox": { + "version": "0.14.33", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.33.tgz", + "integrity": "sha512-rooA/4o7bBof4Ge2VH/eovfNPb/AEEYyrNj03wggc55g5HZD8Pjs/OeWhttgjic3dDcqn0r29bDuvQEdTiUemQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.0.tgz", + "integrity": "sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/viem": { + "version": "2.55.11", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.55.11.tgz", + "integrity": "sha512-RR5MwtdUnFfqw6ZGoFptizywyLOkLuhTL7UafoP3Irf2upXpANakQkLgGqY4H7A7+8JBxjUs6lElGF5zuGjMEw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.33", + "ws": "8.21.0" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..83bafc2 --- /dev/null +++ b/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "podarena-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.62.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "viem": "^2.40.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.3" + } +} diff --git a/web/public/apple-touch-icon.png b/web/public/apple-touch-icon.png new file mode 100644 index 0000000..b3266b0 Binary files /dev/null and b/web/public/apple-touch-icon.png differ diff --git a/web/public/favicon.ico b/web/public/favicon.ico new file mode 100644 index 0000000..5eba2f0 Binary files /dev/null and b/web/public/favicon.ico differ diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..0f3bdbc --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..c18a82d --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,315 @@ +export type Tier = + | "source_signed" + | "aggregator_attested" + | "platform_observed" + | "self_reported"; + +export type Metric = { + key: string; + basis: "net" | "gross"; + value: string; + unit: string; + formula: string; + tier: Tier; + inputs: Record; +}; + +export type DkimInfo = { + verified: boolean; + d_domain: string | null; + selector: string | null; + algo: string | null; + l_tag_present: boolean; + body_hash_matched: boolean; + dns_txt_record: string | null; + dns_captured_at: string | null; + failure_reason: string | null; + signatures: Array<{ + index: number; + domain: string | null; + considered: boolean; + verified: boolean; + reason: string | null; + }>; +}; + +export type Evidence = { + sha256: string; + filename: string | null; + doc_type: string; + channel: string; + institution_id: string | null; + byte_length: number; + received_at: string; + dkim?: DkimInfo; +}; + +export type Leaf = { kind: string; ref: string; digest: string; label: string }; + +export type RecordView = { + state: "open" | "nda_required"; + record: { + slug: string; + name: string; + strategy?: string | null; + chain_key?: string; + }; + profile: string; + viewer_email?: string; + nda?: { version: string; text: string }; + tier: Tier; + tier_label: string; + tier_meaning: string; + snapshot: { + seq: number; + root: string; + metrics_hash: string; + methodology_version: string; + extractor_versions: Record; + created_at: string; + }; + anchor: { + status: string; + chain_id: number; + root: string; + seq: number; + tx_hash: string | null; + block_number: number | null; + confirmed_at: string | null; + error: string | null; + } | null; + metrics: Metric[]; + findings?: Array<{ + kind: string; + severity: string; + detail: string; + as_of: string | null; + }>; + evidence?: Evidence[]; + nav_series?: Array<{ + as_of: string; + amount_minor: number; + currency: string; + tier: Tier; + }>; + flows?: Array<{ + as_of: string; + amount_minor: number; + currency: string; + tier: Tier; + note: string | null; + }>; + merkle_leaves?: Leaf[]; + positions?: Array<{ + as_of: string; + kind: string; + instrument: string | null; + quantity: string | null; + price: string | null; + amount_minor: number | null; + currency: string; + tier: Tier; + }>; + watermark?: { viewer: string; at: string; record: string }; +}; + +export type AppConfig = { + chain_id: number; + rpc_url: string; + explorer_url: string; + registry_address: string | null; + anchoring_enabled: boolean; + demo_fixtures: boolean; + methodology_version: string; + /** Null unless this instance runs the synthetic corpus. */ + demo_admin_token: string | null; +}; + +export const PROFILES = [ + "summary", + "ratios_and_risk", + "full_detail", + "full_plus_positions", +] as const; + +export type ProfileSlug = (typeof PROFILES)[number]; + +export type DisclosureRung = { + profile: ProfileSlug; + rank: number; + bytes: number; + keys: string[]; + metric_keys: string[]; + counts: { + metrics: number; + evidence: number; + findings: number; + nav_series: number; + positions: number; + merkle_leaves: number; + }; +}; + +export type DisclosureMatrix = { + record: { slug: string; name: string }; + profiles: DisclosureRung[]; + sections: string[]; +}; + +export type InviteRow = { + id: string; + viewer_email: string; + profile: ProfileSlug; + status: "active" | "revoked" | "expired"; + nda_accepted_at: string | null; + expires_at: string; + created_at: string; + views: number; + last_seen_at: string | null; +}; + +export type AccessRow = { + at: string; + action: string; + viewer_email: string; + profile: string; + ip: string | null; + chain_tx: string | null; + explorer_url: string | null; +}; + +export type NewInvite = { + token: string; + profile: string; + viewer_email: string; + expires_at: string; + url: string; +}; + +export type RebuildResult = { + seq: number; + root: string; + tier: Tier; + metrics: number; + findings: number; + anchor_status: string; + tx_hash: string | null; + explorer_url: string | null; +}; + +export type AnchorTimeline = { + record: { slug: string; name: string }; + chain_key: string; + chain_id: number; + registry_address: string | null; + snapshots: Array<{ + seq: number; + root: string; + tier: Tier; + created_at: string; + leaf_count: number; + anchor: { + status: string; + tx_hash: string | null; + block_number: number | null; + explorer_url: string | null; + } | null; + }>; +}; + +async function json(response: Response): Promise { + if (!response.ok) { + const body = await response.text(); + throw new Error(`${response.status}: ${body.slice(0, 240)}`); + } + return response.json() as Promise; +} + +export const api = { + config: () => fetch("/api/config").then(json), + records: () => + fetch("/api/records").then( + json>, + ), + demoLogins: () => fetch("/api/demo-logins").then(json), + view: (token: string) => fetch(`/api/view/${token}`).then(json), + acceptNda: (token: string) => + fetch(`/api/view/${token}/nda`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accept: true }), + }).then(json<{ state: string; chain_tx: string | null }>), + proof: (token: string, digest: string) => + fetch(`/api/view/${token}/proof/${digest}`).then( + json<{ root: string; leaf: string; steps: Array<{ sibling: string; side: "left" | "right" }> }>, + ), + anchors: (slug: string) => + fetch(`/api/records/${slug}/anchors`).then(json), + bundleUrl: (token: string) => `/api/view/${token}/bundle`, + documentUrl: (token: string, sha: string) => `/api/view/${token}/document/${sha}`, +}; + +// -- the manager's side ---------------------------------------------------- +// +// Everything below needs the admin bearer token. It is held in localStorage +// rather than a cookie on purpose: no cookie means no ambient authority, so a +// link an allocator clicks can never carry the manager's credentials with it. + +const TOKEN_KEY = "podarena.admin"; + +export const adminToken = { + get: () => localStorage.getItem(TOKEN_KEY), + set: (token: string) => localStorage.setItem(TOKEN_KEY, token), + clear: () => localStorage.removeItem(TOKEN_KEY), +}; + +async function authed(path: string, init: RequestInit = {}): Promise { + const token = adminToken.get(); + const response = await fetch(path, { + ...init, + headers: { + ...(init.headers ?? {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + }); + if (response.status === 401 || response.status === 503) { + throw new Error("unauthorized"); + } + return json(response); +} + +export type DemoLogin = { + role: "fund" | "pm"; + label: string; + scope: string; + token: string; +}; + +export type Me = { + role: "fund" | "pm"; + label: string; + record_slug: string | null; + records: Array<{ slug: string; name: string; strategy: string | null }>; +}; + +export const owner = { + me: () => authed("/api/me"), + view: (slug: string) => authed(`/api/records/${slug}/owner`), + disclosure: (slug: string) => authed(`/api/records/${slug}/disclosure`), + preview: (slug: string, profile: ProfileSlug) => + authed(`/api/records/${slug}/preview/${profile}`), + invites: (slug: string) => authed(`/api/records/${slug}/invites`), + accessLog: (slug: string) => authed(`/api/records/${slug}/access-log`), + createInvite: (slug: string, body: { viewer_email: string; profile: string }) => + authed(`/api/records/${slug}/invites`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + revokeInvite: (id: string) => + authed<{ id: string; revoked_at: string }>(`/api/invites/${id}/revoke`, { + method: "POST", + }), + rebuild: (slug: string) => + authed(`/api/records/${slug}/rebuild`, { method: "POST" }), +}; diff --git a/web/src/components.tsx b/web/src/components.tsx new file mode 100644 index 0000000..80ab6bd --- /dev/null +++ b/web/src/components.tsx @@ -0,0 +1,542 @@ +import { useEffect, useMemo, useState } from "react"; +import type { Evidence, Leaf, Metric, RecordView, Tier } from "./api"; +import { api } from "./api"; +import { readAnchoredRoot, rebuildRoot, verifyProof } from "./verify"; + +// -- formatting ----------------------------------------------------------- + +const RATIO_AS_PERCENT = new Set([ + "twr_cumulative", + "twr_annualized", + "modified_dietz", + "max_drawdown", + "volatility_annualized", + "var_historical", +]); + +const LABELS: Record = { + twr_cumulative: "Cumulative TWR", + twr_annualized: "Annualized TWR", + modified_dietz: "Modified Dietz", + max_drawdown: "Max drawdown", + volatility_annualized: "Volatility", + sharpe: "Sharpe", + sortino: "Sortino", + calmar: "Calmar", + var_historical: "VaR 95%", +}; + +// Metrics where the direction is the point. The design system is achromatic — +// no red/green — so an explicit leading + is what tells a gain from a loss. +const SIGNED = new Set(["twr_cumulative", "twr_annualized", "modified_dietz"]); + +export function formatMetric(metric: Metric): string { + const value = Number(metric.value); + const sign = SIGNED.has(metric.key) && value > 0 ? "+" : ""; + if (RATIO_AS_PERCENT.has(metric.key)) { + return `${sign}${(value * 100).toFixed(2)}%`; + } + return `${sign}${value.toFixed(2)}`; +} + +// Mirrors core/money.py. Two decimals is right far more often than it is +// wrong, but yen has none and a hardcoded /100 would show a fund a hundred +// times poorer than it is. +const MINOR_EXPONENTS: Record = { + JPY: 0, + KRW: 0, + BTC: 8, + ETH: 18, +}; + +export function money(minor: number, currency: string): string { + const exponent = MINOR_EXPONENTS[currency.toUpperCase()] ?? 2; + const major = minor / 10 ** exponent; + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + maximumFractionDigits: 0, + }).format(major); +} + +function shortHex(hex: string, size = 10): string { + return `${hex.slice(0, size)}…${hex.slice(-4)}`; +} + +// -- tier ----------------------------------------------------------------- + +/** The trust hierarchy is encoded by weight, not hue: solid fill for the + * strongest evidence, then outline, then quiet outline, then greyed. A + * colour-blind allocator reads the same ranking a colour-sighted one does. */ +export function TierBadge({ tier, label }: { tier: Tier; label?: string }) { + return ( + + + {label ?? tier.replace(/_/g, " ")} + + ); +} + +// -- metrics -------------------------------------------------------------- + +export function MetricGrid({ metrics, basis }: { metrics: Metric[]; basis: "net" | "gross" }) { + const shown = metrics.filter((m) => m.basis === basis); + if (shown.length === 0) return null; + + return ( +
+ {shown.map((metric) => { + const value = Number(metric.value); + const tone = + metric.key === "max_drawdown" || metric.key === "var_historical" + ? "neg" + : value > 0 + ? "pos" + : value < 0 + ? "neg" + : ""; + return ( +
+
{LABELS[metric.key] ?? metric.key}
+
{formatMetric(metric)}
+ {/* Nothing is displayed without its tier next to it. */} +
{metric.tier.replace(/_/g, " ")}
+
+ ); + })} +
+ ); +} + +// -- chart ---------------------------------------------------------------- + +// Tier is drawn, not coloured: filled disc for signed evidence, filled but +// lighter for attested, hollow for observed, hollow-and-faint for whatever +// somebody typed. Same ranking the badges use. +const DOT: Record = { + source_signed: { fill: "var(--color-onyx)", stroke: "var(--color-onyx)", r: 3.4 }, + aggregator_attested: { fill: "var(--color-steel)", stroke: "var(--color-steel)", r: 3.2 }, + platform_observed: { fill: "var(--surface-card)", stroke: "var(--color-slate-veil)", r: 3.2 }, + self_reported: { fill: "var(--surface-card)", stroke: "var(--color-umber)", r: 3.8 }, +}; + +/** Hand-rolled inline SVG, so the picture and the numbers cannot disagree. + * A charting library that silently interpolates or drops a point would be a + * second, unversioned interpretation of evidence we just went to a lot of + * trouble to pin down. */ +export function NavChart({ + series, + flows, +}: { + series: Array<{ as_of: string; amount_minor: number; currency: string; tier: Tier }>; + flows?: Array<{ as_of: string; amount_minor: number }>; +}) { + if (series.length < 2) return null; + + const width = 720; + const height = 220; + const pad = { top: 16, right: 16, bottom: 26, left: 62 }; + + const values = series.map((point) => point.amount_minor); + const min = Math.min(...values); + const max = Math.max(...values); + const span = max - min || 1; + + const x = (i: number) => + pad.left + (i / (series.length - 1)) * (width - pad.left - pad.right); + const y = (value: number) => + pad.top + (1 - (value - min) / span) * (height - pad.top - pad.bottom); + + const path = series.map((p, i) => `${i === 0 ? "M" : "L"}${x(i)},${y(p.amount_minor)}`).join(" "); + const area = `${path} L${x(series.length - 1)},${height - pad.bottom} L${x(0)},${height - pad.bottom} Z`; + const currency = series[0].currency; + + const flowMarks = (flows ?? []).map((flow) => { + const index = series.findIndex((p) => p.as_of >= flow.as_of); + return { ...flow, index: index === -1 ? series.length - 1 : index }; + }); + + return ( +
+ + + {/* The dawn arc draws the curve. It is the only colour on the page, + and it is the same one on the hero and the brand mark. */} + + + + + + + + + + + + {[0, 0.5, 1].map((fraction) => { + const value = min + fraction * span; + return ( + + + + {money(value, currency)} + + + ); + })} + + + + + {series.map((point, i) => { + const dot = DOT[point.tier]; + return ( + + + {point.as_of} · {money(point.amount_minor, currency)} · {point.tier} + + + ); + })} + + {/* Deposits and withdrawals differ by dash pattern, not by colour. */} + {flowMarks.map((flow) => ( + 0 ? "5 4" : "1.5 4"} + strokeOpacity="0.34" + > + + {flow.amount_minor > 0 ? "Deposit" : "Withdrawal"} {flow.as_of} + + + ))} + + + {series[0].as_of} + + + {series[series.length - 1].as_of} + + +

+ Long dashes are deposits, fine dashes are withdrawals. Filled points are signed by + the bank; hollow ones are weaker. Compute a return from the line alone and you'll + get it wrong everywhere money moved, which is why we don't. +

+
+ ); +} + +// -- evidence ------------------------------------------------------------- + +/** `token` is null in the manager's preview, where there is no invite to + * download against — the row still renders, the link does not. */ +export function EvidenceTable({ + evidence, + token, +}: { + evidence: Evidence[]; + token: string | null; +}) { + return ( + + + + + + + + + + {evidence.map((item) => { + const dkim = item.dkim; + const ignored = (dkim?.signatures ?? []).filter((s) => !s.considered); + return ( + + + + + + + ); + })} + +
DocumentSignatureCaptured key +
+
{item.filename ?? item.sha256.slice(0, 16)}
+
{shortHex(item.sha256, 16)}
+
{item.doc_type.replace(/_/g, " ")}
+
+ {dkim?.verified ? ( + + ) : ( + + )} +
+ {dkim?.d_domain ?? "no signature"} + {dkim?.selector ? ` · ${dkim.selector}` : ""} +
+ {dkim?.failure_reason && ( +
+ {dkim.failure_reason} +
+ )} + {ignored.map((sig) => ( +
+ Ignored {sig.domain}: {sig.reason} +
+ ))} +
+ {dkim?.dns_txt_record ? ( + <> +
{dkim.dns_txt_record.slice(0, 28)}…
+
captured {dkim.dns_captured_at?.slice(0, 10)}
+ + ) : ( + "—" + )} +
+ {token ? ( + + .eml + + ) : ( + .eml + )} +
+ ); +} + +// -- independent verification -------------------------------------------- + +type CheckState = "pending" | "running" | "ok" | "bad"; + +type Check = { name: string; state: CheckState; detail: string }; + +/** The panel that decides whether "verified" means anything. + * Every check here runs in this browser, against an RPC the viewer picks. */ +export function VerifyPanel({ + view, + token, + chainId, + defaultRpc, + registry, + chainKey, +}: { + view: RecordView; + token: string; + chainId: number; + defaultRpc: string; + registry: string | null; + chainKey: string | null; +}) { + const [rpcUrl, setRpcUrl] = useState(defaultRpc); + const [checks, setChecks] = useState([]); + const [running, setRunning] = useState(false); + + const leaves: Leaf[] = view.merkle_leaves ?? []; + + const run = async () => { + setRunning(true); + const results: Check[] = []; + const push = (check: Check) => { + results.push(check); + setChecks([...results]); + }; + + // 1. Does the published leaf set actually fold to the published root? + try { + const rebuilt = await rebuildRoot(leaves.map((leaf) => leaf.digest)); + const ok = rebuilt.toLowerCase() === view.snapshot.root.toLowerCase(); + push({ + name: `Re-fold all ${leaves.length} leaves`, + state: ok ? "ok" : "bad", + detail: ok + ? `${rebuilt.slice(0, 20)}… computed here, in this tab` + : `we computed ${rebuilt.slice(0, 20)}…, the server claims ${view.snapshot.root.slice(0, 20)}…`, + }); + } catch (error) { + push({ name: "Re-fold all leaves", state: "bad", detail: String(error) }); + } + + // 2. Does a server-supplied Merkle path agree with our own fold? + try { + const sample = leaves[0]; + const proof = await api.proof(token, sample.digest); + const ok = await verifyProof(proof, view.snapshot.root); + push({ + name: "Check a Merkle path", + state: ok ? "ok" : "bad", + detail: ok ? sample.label : "the path doesn't lead to the root", + }); + } catch (error) { + push({ name: "Check a Merkle path", state: "bad", detail: String(error) }); + } + + // 3. The one that matters: is that root on a chain we do not run? + if (registry && chainKey) { + try { + const head = await readAnchoredRoot({ + rpcUrl, + chainId, + registry, + recordKey: chainKey, + }); + const ok = head.root.toLowerCase() === view.snapshot.root.toLowerCase(); + push({ + name: `Compare against chain ${chainId}`, + state: ok ? "ok" : "bad", + detail: ok + ? `sequence ${head.seq}, anchored ${new Date(Number(head.ts) * 1000).toISOString().slice(0, 19)}Z` + : `chain says ${head.root.slice(0, 20)}…`, + }); + } catch (error) { + push({ + name: `Compare against chain ${chainId}`, + state: "bad", + detail: `RPC call failed: ${String(error).slice(0, 120)}`, + }); + } + } else { + push({ + name: "Compare against chain", + state: "pending", + detail: "this instance has no registry address configured, so nothing was anchored", + }); + } + + setRunning(false); + }; + + const allOk = checks.length > 0 && checks.every((c) => c.state === "ok"); + + return ( +
+
+

Verify it yourself

+ runs in this browser +
+

+ Every check below runs in this tab, on your machine. Point it at any Monad RPC you + like. The whole idea is that ours isn't in the trust path. +

+ +
+ setRpcUrl(event.target.value)} + aria-label="RPC endpoint" + /> + +
+ + {checks.map((check) => ( +
+ + {check.state === "ok" ? "PASS" : check.state === "bad" ? "FAIL" : "SKIP"} + +
+
{check.name}
+
{check.detail}
+
+
+ ))} + + {allOk && ( +

All of it checked out, here. Nothing above took our word for anything.

+ )} + +

+ Or don't trust this page either.{" "} + Download the bundle — the .eml files, the DNS + keys as we captured them, every Merkle proof — and run{" "} + podarena-verify bundle on your own machine, offline. +

+
+ ); +} + +// -- watermark ------------------------------------------------------------ + +export function Watermark({ viewer, at }: { viewer: string; at: string }) { + const text = useMemo(() => `${viewer} · ${at.slice(0, 19)}Z`, [viewer, at]); + return ( +
+ + {Array.from({ length: 14 }).map((_, i) => ( + + {text} +
+
+ ))} +
+
+ ); +} + +export function useTitle(title: string) { + useEffect(() => { + document.title = title; + }, [title]); +} diff --git a/web/src/demo.ts b/web/src/demo.ts new file mode 100644 index 0000000..508b685 --- /dev/null +++ b/web/src/demo.ts @@ -0,0 +1,66 @@ +/** The staged demo, in one place. + * + * The seeder mints these invite tokens deterministically (demo/seed.py), so + * the links survive a reseed and a redeploy. Real invites get 32 bytes from + * `secrets.token_urlsafe` and only their hash is ever stored — see the note in + * `_invite()` on why the fixtures are allowed to be an exception. */ + +export type DemoInvite = { + token: string; + record: string; + viewer: string; + profile: string; + blurb: string; +}; + +export const DEMO_INVITES: DemoInvite[] = [ + { + token: "demo-meridian-full", + record: "Meridian Global Macro", + viewer: "allocator@example-endowment.org", + profile: "full_detail", + blurb: "Twelve months, every one signed by the broker's own key. This is what good looks like.", + }, + { + token: "demo-northwind-full", + record: "Northwind Partners", + viewer: "allocator@example-endowment.org", + profile: "full_detail", + blurb: + "The same twelve numbers, with one month typed into a spreadsheet. Watch what it costs.", + }, + { + token: "demo-evidence-lab", + record: "Evidence Lab", + viewer: "allocator@example-endowment.org", + profile: "full_detail", + blurb: + "Five ways a signature fails: a retired key, an l= tag, one flipped byte, a hand-forward, a stranger.", + }, + { + token: "demo-meridian-summary", + record: "Meridian Global Macro", + viewer: "cautious@example-fund.com", + profile: "summary", + blurb: + "The same fund as the first link, through the narrowest window. Open both and compare.", + }, +]; + +export const PROFILE_LABELS: Record = { + summary: "Summary", + ratios_and_risk: "Ratios & risk", + full_detail: "Full detail", + full_plus_positions: "Full + positions", +}; + +export const PROFILE_MEANING: Record = { + summary: "Headline return only. No risk ratios, no evidence, no chart.", + ratios_and_risk: "Adds volatility, Sharpe, Sortino, Calmar, drawdown, VaR.", + full_detail: "Adds the evidence, the findings, the NAV series and the Merkle leaves.", + full_plus_positions: "Adds positions, instrument by instrument. The rung managers lose sleep over.", +}; + +export function kb(bytes: number): string { + return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} kB`; +} diff --git a/web/src/landing.tsx b/web/src/landing.tsx new file mode 100644 index 0000000..9936bad --- /dev/null +++ b/web/src/landing.tsx @@ -0,0 +1,348 @@ +import { useEffect, type CSSProperties } from "react"; +import { useQuery } from "@tanstack/react-query"; + +import { api } from "./api"; +import { useTitle } from "./components"; +import { DEMO_INVITES, PROFILE_LABELS } from "./demo"; +import { Page } from "./shell"; + +/** Scroll reveal, in about a dozen lines instead of a library. + * + * Anything that should arrive rather than simply be there carries `.reveal`; + * this adds `.in` the first time it crosses into view and then stops watching + * it. A `--d` custom property on the element staggers siblings. + * + * Reduced-motion visitors get the finished state on mount. That is the same + * page with the theatre switched off, which is the point: none of the motion + * here carries information. */ +function useReveal() { + useEffect(() => { + const nodes = Array.from(document.querySelectorAll(".reveal")); + const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (still || !("IntersectionObserver" in window)) { + nodes.forEach((node) => node.classList.add("in")); + return; + } + const watcher = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + entry.target.classList.add("in"); + watcher.unobserve(entry.target); + } + }, + { threshold: 0.12, rootMargin: "0px 0px -8% 0px" }, + ); + nodes.forEach((node) => watcher.observe(node)); + return () => watcher.disconnect(); + }, []); +} + +const DEMO_LINK = `/view/${DEMO_INVITES[0].token}`; + +const delay = (seconds: number) => ({ "--d": `${seconds}s` }) as CSSProperties; + +/** Line-art glyphs, hand-drawn like the charts and for the same reason: an + * icon font is a network dependency on a page whose entire argument is that + * it does not need anyone else's server. */ +function Glyph({ name }: { name: "mail" | "key" | "block" | "eye" }) { + const shapes = { + mail: ( + <> + + + + ), + key: ( + <> + + + + ), + block: ( + <> + + + + ), + eye: ( + <> + + + + ), + }; + return ( + + {shapes[name]} + + ); +} + +/** The hero's right half: one statement, checked on a loop. + * + * It is the whole product in six lines. An email nobody asked the bank to + * send, a signature header, two numbers, and a verdict that lands a beat + * later. Pure CSS, no state, decorative to a screen reader. */ +function ProofCard() { + return ( +
+
+ statement-2025-11.eml + sent by your broker +
+ +
+ DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; + d=ibkr-demo.podarena.test; s=mail2024; bh=T5hK9x… +
+ +
+
+ Net asset value + $41,207,338.22 +
+
+ Annualized TWR, net + +21.89% +
+
+ +
+ checking RSA… + + + SOURCE SIGNED + +
+
+ ); +} + +/** Twelve months as twelve marks. Solid is a signature, dashed is somebody's + * keyboard. The dashed one is the whole argument of that section, so it keeps + * breathing after the rest have settled. */ +function Ticks({ typed }: { typed?: number }) { + return ( +
+ {Array.from({ length: 12 }).map((_, i) => ( + + ))} +
+ ); +} + +export function Home() { + useTitle("PodShop Arena"); + useReveal(); + const config = useQuery({ queryKey: ["config"], queryFn: api.config }); + + const hero = ( +
+
+
+

Anyone can type a track record.

+

+ Your broker already signed the real one. It has been sitting in your inbox + this whole time. +

+ +
+ +
+
+ ); + + return ( + + {/* 1. The problem, as the three things an allocator can actually do. */} +
+
+
The problem
+

Three ways to prove a number. Two don't work.

+
+
+
+ +

A PDF

+

The manager typed it himself.

+
+
+ +

Our word for it

+

+ A platform vouching for its own customer proves nothing. +

+
+
+ +

The DKIM signature

+

Already sent. Nobody had to agree to anything.

+
+
+
+ + {/* 2. What we do with it. Four steps because there are four. */} +
+
+
How it works
+

Four steps. The bank never finds out.

+
+
    +
  1. + + + +
    01
    +

    Forward the statement

    +

    The email your broker already sends.

    +
  2. +
  3. + + + +
    02
    +

    Check the signature

    +

    + Real RSA, and we copy the DNS key down before it retires. +

    +
  4. +
  5. + + + +
    03
    +

    Anchor on Monad

    +

    + Every ingest. 0.3s blocks, so we prove the moment, not the day. +

    +
  6. +
  7. + + + +
    04
    +

    They check it without us

    +

    47 leaves re-folded in the allocator's browser.

    +
  8. +
+
+ + {/* 3. The punchline. Same number, different evidence, one glance. */} +
+
+
Why it bites
+

One typed row costs the whole year.

+
+ +

+ Identical to the digit. A metric carries the weakest evidence that fed it. +

+
+ + {/* 4. Three seats. Which door you came through decides what exists, so + the doors are left to say it themselves. */} +
+ + + {DEMO_INVITES.map((invite, i) => ( + +
+
{invite.record}
+
{invite.blurb}
+
+ {PROFILE_LABELS[invite.profile]} +
+ ))} +
+ + {/* Only when there is something to point at. An instance with no + registry says nothing here rather than explaining its own absence. */} + {config.data?.registry_address && ( +

+ Chain {config.data.chain_id} ·{" "} + + {config.data.registry_address} + +

+ )} +
+ ); +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..929ba6b --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,317 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api, type RecordView } from "./api"; +import { + EvidenceTable, + MetricGrid, + NavChart, + TierBadge, + VerifyPanel, + Watermark, + money, + useTitle, +} from "./components"; +import { PROFILE_LABELS, PROFILE_MEANING } from "./demo"; +import { Home } from "./landing"; +import { ManageHome, ManageRecord } from "./manage"; +import { Page } from "./shell"; +import "./styles.css"; + +const client = new QueryClient({ + defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } }, +}); + +// Four routes and no library. A router that needs a code-generation step to +// serve these would be more machinery than the problem has. +type Route = + | { name: "home" } + | { name: "view"; token: string } + | { name: "manage" } + | { name: "manage-record"; slug: string }; + +function route(): Route { + const path = window.location.pathname; + const view = path.match(/^\/view\/([^/]+)/); + if (view) return { name: "view", token: view[1] }; + const record = path.match(/^\/manage\/([^/]+)/); + if (record) return { name: "manage-record", slug: record[1] }; + if (path.startsWith("/manage")) return { name: "manage" }; + return { name: "home" }; +} + +function NdaGate({ view, token }: { view: RecordView; token: string }) { + const queryClient = useQueryClient(); + const accept = useMutation({ + mutationFn: () => api.acceptNda(token), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["view", token] }), + }); + + return ( + +
+

{view.record.name}

+

+ Invited as {view.viewer_email} · disclosure + profile {view.profile} +

+

{view.nda?.text}

+

+ There are no numbers on this page to peek at, and that's deliberate. Nothing + behind this gate has reached your browser yet. Your consent gets written to an + append-only log and put on-chain first. Only then does the data move. +

+ + {accept.isError && ( +

+ {String(accept.error)} +

+ )} +
+
+ ); +} + +function View({ token }: { token: string }) { + const config = useQuery({ queryKey: ["config"], queryFn: api.config }); + const view = useQuery({ queryKey: ["view", token], queryFn: () => api.view(token) }); + + useTitle(view.data ? `${view.data.record.name} — PodShop Arena` : "PodShop Arena"); + + if (view.isLoading) + return ( + +

Loading…

+
+ ); + if (view.isError) + return ( + +
+

This link doesn't open anything

+

{String(view.error)}

+

+ Invites are tied to one person, they expire, and they can be revoked. That's + what makes them worth handing out. +

+
+
+ ); + + const data = view.data!; + if (data.state === "nda_required") return ; + + const anchors = data.anchor; + + return ( + + {data.watermark && } + +
+
+

{data.record.name}

+ +
+

+ {data.record.strategy ? `${data.record.strategy} · ` : ""} + {data.tier_meaning} +

+
+ + {/* The allocator's half of the disclosure contract. Saying which window + you are looking through is part of the evidence: a number withheld + silently is indistinguishable from a number that does not exist. */} +
+
+ Your disclosure profile + + {PROFILE_LABELS[data.profile] ?? data.profile} + +
+

+ {PROFILE_MEANING[data.profile]} Anything above that rung was never built into + this response in the first place. If you want more, ask {data.record.name}. We + can't give it to you. +

+
+ +
+
+

Net of fees

+ what the investor received +
+

+ The number that ends up in the investor's account, after everyone took their cut. + Under each one is the weakest evidence that fed it. A single typed-in row drags + the whole metric down to typed-in, and that's on purpose. +

+ +
+ + {data.metrics.some((m) => m.basis === "gross") && ( +
+

Gross

+

+ The same numbers with the fees added back, using terms the manager states. No + broker signed for these. We show them next to net because showing gross on its + own is how performance advertising goes bad. +

+ +
+ )} + + {data.nav_series && data.nav_series.length > 1 && ( +
+
+

Net asset value

+ {data.nav_series.length} observations +
+

+ {data.nav_series.length} observations ·{" "} + {money(data.nav_series[data.nav_series.length - 1].amount_minor, data.nav_series[0].currency)}{" "} + at the last one +

+ +
+ )} + + {config.data && ( + + )} + + {data.findings && data.findings.length > 0 && ( +
+

What didn't add up

+

+ We write these down instead of hiding them. A page with no discrepancies on it + isn't cleaner than this one. It's just quieter. +

+ {data.findings.map((finding, index) => ( +
+
+ {finding.kind} · {finding.severity} + {finding.as_of ? ` · ${finding.as_of}` : ""} +
+
{finding.detail}
+
+ ))} +
+ )} + + {data.evidence && ( +
+
+

Evidence

+ {data.evidence.length} documents +
+

+ The actual documents. Each one carries the bank's signature and the DNS key + exactly as it stood the first time we looked. +

+ +
+ )} + + {data.positions && data.positions.length > 0 && ( +
+

Positions

+

+ Opt-in, per viewer, every single time. Most people never see this section. +

+ + + + + + + + + + + + {data.positions.map((position, index) => ( + + + + + + + + ))} + +
DateInstrumentQuantityPriceAmount
{position.as_of}{position.instrument}{position.quantity}{position.price} + {position.amount_minor !== null + ? money(position.amount_minor, position.currency) + : "—"} +
+
+ )} + +
+
+

Snapshot {data.snapshot.seq}

+ {data.snapshot.methodology_version} +
+
+
+ Merkle root + {data.snapshot.root} +
+
+ Metrics hash + {data.snapshot.metrics_hash} +
+
+ Extractors + + {Object.entries(data.snapshot.extractor_versions) + .map(([id, version]) => `${id} v${version}`) + .join(" · ")} + +
+
+ Anchor + + {anchors?.tx_hash ? ( + + {anchors.tx_hash.slice(0, 18)}… (block {anchors.block_number}) + + ) : ( + (anchors?.error ?? "not anchored") + )} + +
+
+
+
+ ); +} + +function App() { + const current = route(); + if (current.name === "view") return ; + if (current.name === "manage-record") return ; + if (current.name === "manage") return ; + return ; +} + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/web/src/manage.tsx b/web/src/manage.tsx new file mode 100644 index 0000000..284aaa7 --- /dev/null +++ b/web/src/manage.tsx @@ -0,0 +1,834 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + PROFILES, + adminToken, + api, + owner, + type DisclosureMatrix, + type InviteRow, + type ProfileSlug, + type RecordView, +} from "./api"; +import { EvidenceTable, MetricGrid, NavChart, TierBadge, useTitle } from "./components"; +import { DEMO_INVITES, PROFILE_LABELS, PROFILE_MEANING, kb } from "./demo"; +import { Page } from "./shell"; + +// -- login ---------------------------------------------------------------- + +/** The console's door: pick a seat, click it, you're in. + * + * On a fixtures instance /api/demo-logins hands over one token per seat, so a + * three-minute demo is a click rather than three minutes of typing — and the + * buttons themselves make the point, because "Fund · all 3 pods" next to + * "Meridian · this pod only" says what the wall is before you've signed in. + * On an instance holding real evidence that list is empty and all you get is + * the box, because `demo_fixtures` and real evidence cannot both be true. */ +function LoginGate({ onAuthed }: { onAuthed: () => void }) { + const logins = useQuery({ queryKey: ["demo-logins"], queryFn: api.demoLogins }); + const [typed, setTyped] = useState(null); + + // The box arrives holding the fund's demo token. Nothing to copy from + // anywhere, nothing to look up — press Go. Typing takes over the moment you + // touch it, and on a real instance there is nothing to prefill so it starts + // empty, which is also the honest state. + const value = typed ?? logins.data?.[0]?.token ?? ""; + + const submit = (token: string) => { + if (!token.trim()) return; + adminToken.set(token.trim()); + onAuthed(); + }; + + return ( + +
+

Sign in

+

+ Two seats inside the wall. Allocators don't have one. +

+ + {logins.data?.map((login) => ( + + ))} + +
+ setTyped(event.target.value)} + onKeyDown={(event) => event.key === "Enter" && submit(value)} + aria-label="Admin token" + className="mono" + /> + +
+
+
+ ); +} + +/** Reads the stored token once into state, so signing in or out re-renders the + * console without a page reload. */ +function useAdminGate(): { authed: boolean; signIn: () => void; signOut: () => void } { + const [token, setToken] = useState(() => adminToken.get()); + return { + authed: !!token, + signIn: () => setToken(adminToken.get()), + signOut: () => { + adminToken.clear(); + setToken(null); + }, + }; +} + +// -- fund list ------------------------------------------------------------ + +export function ManageHome() { + const gate = useAdminGate(); + const me = useQuery({ queryKey: ["me"], queryFn: owner.me, enabled: gate.authed }); + + useTitle(me.data?.role === "pm" ? "PM console — PodShop Arena" : "Fund console — PodShop Arena"); + + if (!gate.authed) return ; + if (me.isError) return ; + if (!me.data) + return ( + +

Loading…

+
+ ); + + // A PM has no roster to land on — /manage *is* their pod. Rendering the record + // page here rather than redirecting keeps the URL honest about what they can + // reach, which is one thing. + if (me.data.role === "pm" && me.data.record_slug) + return ; + + return ( + +
+
+

The roster

+ +
+

+ Every pod on the platform, and every key any of them has handed out. This page + is the one thing a portfolio manager cannot see — sign in as one and it's gone, + because the list is filtered on the server, not in your browser. +

+
+ +
+
+

Pods

+ {me.data.records.length} pods +
+ {me.data.records.map((record) => ( + +
+
{record.name}
+
{record.strategy}
+
+ + → + +
+ ))} +
+ +
+
+

Who sees what

+ three seats +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
SeatCredentialReach
+ Fund + One admin tokenEvery pod, plus the power to take a new one on.
+ Portfolio manager + pm_<pod>_<mac> + Their own pod. Asking after another one returns 404, not 403 — they don't + even learn it exists. +
+ Allocator + No account at all + One link, bound to their address and a disclosure profile, revocable + mid-conversation. +
+ +

Allocator links, pre-loaded

+ + + {DEMO_INVITES.map((invite) => ( + + + + + + + ))} + +
{invite.viewer}{invite.record} + {PROFILE_LABELS[invite.profile]} + + open +
+
+
+ ); +} + +// -- one fund ------------------------------------------------------------- + +export function ManageRecord({ slug }: { slug: string }) { + const gate = useAdminGate(); + const queryClient = useQueryClient(); + const [profile, setProfile] = useState("summary"); + + const me = useQuery({ queryKey: ["me"], queryFn: owner.me, enabled: gate.authed }); + const seat = me.data?.role ?? "fund"; + const scope = + seat === "pm" ? "this pod only" : `${me.data?.records.length ?? 0} pods`; + + const view = useQuery({ + queryKey: ["owner", slug], + queryFn: () => owner.view(slug), + enabled: gate.authed, + }); + const matrix = useQuery({ + queryKey: ["disclosure", slug], + queryFn: () => owner.disclosure(slug), + enabled: gate.authed, + }); + const preview = useQuery({ + queryKey: ["preview", slug, profile], + queryFn: () => owner.preview(slug, profile), + enabled: gate.authed, + }); + const invites = useQuery({ + queryKey: ["invites", slug], + queryFn: () => owner.invites(slug), + enabled: gate.authed, + }); + const access = useQuery({ + queryKey: ["access", slug], + queryFn: () => owner.accessLog(slug), + enabled: gate.authed, + }); + const anchors = useQuery({ + queryKey: ["anchors", slug], + queryFn: () => api.anchors(slug), + enabled: gate.authed, + }); + + useTitle(view.data ? `${view.data.record.name} — console` : "Manager console"); + + const rebuild = useMutation({ + mutationFn: () => owner.rebuild(slug), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["owner", slug] }); + queryClient.invalidateQueries({ queryKey: ["anchors", slug] }); + queryClient.invalidateQueries({ queryKey: ["disclosure", slug] }); + }, + }); + + if (!gate.authed) return ; + if (view.isError && String(view.error).includes("unauthorized")) + return ; + if (view.isLoading) + return ( + +

Loading…

+
+ ); + if (view.isError) + return ( + +
+

Could not open {slug}

+

{String(view.error)}

+
+
+ ); + + const data = view.data!; + + return ( + +
+
+

{data.record.name}

+
+ + + +
+
+

+ {data.record.strategy} · snapshot {data.snapshot.seq} ·{" "} + {data.snapshot.root.slice(0, 22)}… +

+ {seat === "pm" && ( +

+ You're the PM here. This is your pod and the only one you can open — the fund + sees this page too, alongside everyone else's. +

+ )} + {rebuild.isSuccess && ( +

+ Snapshot {rebuild.data.seq} built — {rebuild.data.metrics} metrics,{" "} + {rebuild.data.findings} findings, anchor {rebuild.data.anchor_status} + {rebuild.data.explorer_url && ( + <> + {" · "} + transaction + + )} +

+ )} + {rebuild.isError && ( +

+ {String(rebuild.error)} +

+ )} +
+ + + + + +
+
+

What you see

+ nothing withheld +
+

+ Every metric the engine produced, on every basis. This is as wide as it goes. + It's the same function that builds an allocator's response, just run with the + ceiling taken off. +

+ + {data.metrics.some((metric) => metric.basis === "gross") && ( + <> +

Gross

+ + + )} + {data.nav_series && data.nav_series.length > 1 && ( +
+ +
+ )} +
+ + + +
+
+

Who opened it

+ {access.data?.length ?? 0} events +
+

+ Append-only, and each row goes on-chain as it's written. You can't quietly delete + a view here. Neither can we. +

+ {(access.data?.length ?? 0) === 0 ? ( +

Nobody has opened this one yet.

+ ) : ( + + + + + + + + + + + + {access.data?.map((event, index) => ( + + + + + + + + ))} + +
WhenWhoActionProfileOn-chain
{event.at.slice(0, 19).replace("T", " ")}{event.viewer_email}{event.action} + + {PROFILE_LABELS[event.profile] ?? event.profile} + + + {event.explorer_url ? ( + {event.chain_tx?.slice(0, 14)}… + ) : ( + not anchored + )} +
+ )} +
+ +
+
+

Anchor timeline

+ {anchors.data?.snapshots.length ?? 0} snapshots +
+

+ Public on purpose. Every ingest gets a number, so a month you quietly drop leaves + a gap an allocator can count without having to ask you about it. +

+ + + + + + + + + + + + {anchors.data?.snapshots.map((snapshot) => ( + + + + + + + + ))} + +
SeqRootTierLeavesAnchor
{snapshot.seq}{snapshot.root.slice(0, 20)}… + + {snapshot.leaf_count} + {snapshot.anchor?.explorer_url ? ( + + {snapshot.anchor.tx_hash?.slice(0, 14)}… + + ) : ( + {snapshot.anchor?.status ?? "none"} + )} +
+
+
+ ); +} + +// -- the disclosure fence, made visible ------------------------------------ + +const SECTION_LABELS: Record = { + metrics: "Metrics", + findings: "Reconciliation findings", + evidence: "Evidence documents", + nav_series: "NAV series", + flows: "Cash flows", + merkle_leaves: "Merkle leaves", + positions: "Positions", + snapshot: "Snapshot header", + anchor: "Anchor", + record: "Record identity", + tier: "Tier", + tier_label: "Tier label", + tier_meaning: "Tier meaning", + profile: "Profile", +}; + +/** The panel this whole console exists for. + * + * A manager's question is never "is it hidden in the UI" — it is "did it leave + * the building". So each rung here is the projection actually run, and the + * number in the column is the serialized size of what would go on the wire. A + * dash means the key is absent from the JSON, not styled out of the page. */ +function DisclosurePanel({ + matrix, + profile, + onProfile, + ownerBytes, +}: { + matrix?: DisclosureMatrix; + profile: ProfileSlug; + onProfile: (profile: ProfileSlug) => void; + ownerBytes: number; +}) { + if (!matrix) return null; + + const varying = matrix.sections.filter((section) => { + const present = matrix.profiles.map((rung) => rung.keys.includes(section)); + return present.some(Boolean) && !present.every(Boolean); + }); + const always = matrix.sections.filter((section) => + matrix.profiles.every((rung) => rung.keys.includes(section)), + ); + + return ( +
+
+

What each allocator can see

+ measured, not asserted +
+

+ One row per section, one column per profile. A dash doesn't mean hidden. It means + the key isn't in the response at all. Click a column and we'll render that exact + payload below. +

+ +
+ + + + + {matrix.profiles.map((rung) => ( + + ))} + + + + + + {matrix.profiles.map((rung) => ( + + ))} + + {varying.map((section) => ( + + + {matrix.profiles.map((rung) => { + const present = rung.keys.includes(section); + const count = + rung.counts[section as keyof typeof rung.counts] ?? undefined; + return ( + + ); + })} + + ))} + + + {matrix.profiles.map((rung) => ( + + ))} + + +
Section onProfile(rung.profile)} + > + {PROFILE_LABELS[rung.profile] ?? rung.profile} +
Bytes on the wire onProfile(rung.profile)} + > + {kb(rung.bytes)} +
{SECTION_LABELS[section] ?? section} onProfile(rung.profile)} + > + {present ? ( + {count ?? "✓"} + ) : ( + + )} +
Metrics onProfile(rung.profile)} + > + {rung.counts.metrics} +
+
+ +

+ {always.length} sections travel at every profile:{" "} + {always.map((section) => SECTION_LABELS[section] ?? section).join(", ")}. That's + identity and provenance. Strip those out and there's nothing left to check. +

+

+ You're looking at {kb(ownerBytes)}. Your narrowest allocator gets{" "} + {kb(matrix.profiles[0]?.bytes ?? 0)} of it. +

+
+ ); +} + +/** The same payload the allocator's browser would receive, rendered with the + * same components their page uses. Not a mock-up — one fetch against the real + * projection, so this and the allocator's screen cannot drift. */ +function AllocatorPreview({ + profile, + preview, + loading, + matrix, +}: { + profile: ProfileSlug; + preview?: RecordView; + loading: boolean; + matrix?: DisclosureMatrix; +}) { + const rung = matrix?.profiles.find((row) => row.profile === profile); + const absent = (matrix?.sections ?? []).filter( + (section) => rung && !rung.keys.includes(section), + ); + + return ( +
+
+

Previewing as an allocator

+ {PROFILE_LABELS[profile]} +
+

{PROFILE_MEANING[profile]}

+ + {loading &&

Rendering the allocator's payload…

} + + {preview && ( + <> +
+
+ /view/… · {PROFILE_LABELS[profile]} + {kb(rung?.bytes ?? 0)} +
+
+ + + {preview.nav_series && preview.nav_series.length > 1 && ( +
+

Net asset value

+ +
+ )} + + {preview.evidence && preview.evidence.length > 0 && ( +
+

Evidence

+ +
+ )} + + {preview.positions && preview.positions.length > 0 && ( +
+

Positions

+

+ {preview.positions.length} rows, instrument by instrument. Think hard + before you grant this one. +

+
+ )} +
+
+ + {absent.length > 0 && ( +
+

Not in this response

+
+ {absent.map((section) => ( + + {SECTION_LABELS[section] ?? section} + + ))} +
+

+ Missing from the JSON, not hidden by the page. Open dev tools on the + allocator's browser and you'll find nothing, because there's nothing there. +

+
+ )} + + )} +
+ ); +} + +// -- invites --------------------------------------------------------------- + +function InvitePanel({ slug, invites }: { slug: string; invites?: InviteRow[] }) { + const queryClient = useQueryClient(); + const [email, setEmail] = useState("allocator@example-endowment.org"); + const [profile, setProfile] = useState("summary"); + const [copied, setCopied] = useState(false); + + const refresh = () => { + queryClient.invalidateQueries({ queryKey: ["invites", slug] }); + queryClient.invalidateQueries({ queryKey: ["access", slug] }); + }; + + const create = useMutation({ + mutationFn: () => owner.createInvite(slug, { viewer_email: email, profile }), + onSuccess: refresh, + }); + const revoke = useMutation({ + mutationFn: (id: string) => owner.revokeInvite(id), + onSuccess: refresh, + }); + + const copy = (url: string) => { + navigator.clipboard?.writeText(url); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+

Who holds a key

+ {invites?.length ?? 0} invites +
+

+ An allocator never gets an account. They get one link, tied to their address and to + a profile. It expires, and you can revoke it in the middle of a conversation. We + only store the token's hash, so this list can't re-send a link even if you ask it + to. Issue a new one. +

+ +
+ setEmail(event.target.value)} + aria-label="Allocator email" + /> + + +
+ + {create.isSuccess && ( +
+
+ Link — shown once + {create.data.url} +
+
+ + + open it as the allocator → + +
+
+ )} + {create.isError && ( +

+ {String(create.error)} +

+ )} + + {(invites?.length ?? 0) > 0 && ( + + + + + + + + + + + + {invites?.map((invite) => ( + + + + + + + + + ))} + +
AllocatorProfileStatusNDAViews +
{invite.viewer_email} + + {PROFILE_LABELS[invite.profile] ?? invite.profile} + + + {invite.status} + + {invite.nda_accepted_at + ? invite.nda_accepted_at.slice(0, 10) + : "not accepted"} + {invite.views} + {invite.status === "active" && ( + + )} +
+ )} +
+ ); +} diff --git a/web/src/shell.tsx b/web/src/shell.tsx new file mode 100644 index 0000000..1215667 --- /dev/null +++ b/web/src/shell.tsx @@ -0,0 +1,96 @@ +import type { ReactNode } from "react"; + +/** Which seat you're in. Three of them, and the whole product is the wall + * between them: + * + * - `fund` — the platform. Sees every pod on the roster. + * - `pm` — one pod. Sees their own record and nothing next door. + * - (none) — the allocator, or the public site. One link, no account. + */ +export type Seat = "fund" | "pm"; + +const SEAT_PILL: Record = { + fund: "fund console", + pm: "PM console", +}; + +/** Page chrome, shared by the public site and the two consoles. + * + * The three sides deliberately look related but not identical: each console + * gets a coloured rail under the masthead, so anyone glancing at a screenshot + * can tell in a quarter second whose eyes they are looking through. Confusing + * those is the expensive mistake this product exists to prevent. */ +export function Masthead({ seat }: { seat?: Seat }) { + return ( +
+
+ + PodShop Arena + + {seat ? ( +
+ {SEAT_PILL[seat]} + {seat === "fund" && ( + + all pods + + )} +
+ ) : ( +
A track record you don't have to take on faith
+ )} +
+
+ ); +} + +export function SiteFooter() { + return ( +
+
+
+ PodShop Arena +
+
+
+ ); +} + +/** The frame every route sits in: sticky header, optional full-bleed hero, + * content column, dark footer bookending the dawn arc. */ +export function Page({ + hero, + seat, + scope, + children, +}: { + hero?: ReactNode; + seat?: Seat; + /** What this seat can reach — printed in the rail, because "you are the PM" + * is abstract and "Meridian Global Macro only" is not. */ + scope?: string; + children: ReactNode; +}) { + return ( +
+ + {seat && ( +
+
+ {seat === "fund" ? ( + <> + FUND · {scope ?? "every pod on the platform"} · none of + this has crossed the wire + + ) : ( + PORTFOLIO MANAGER + )} +
+
+ )} + {hero} +
{children}
+ +
+ ); +} diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..d29c405 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,1587 @@ +/* "New Genre" — the visual system described in DESIGN.md §6, applied here. + Near-white surfaces, blue-black Onyx text, and exactly one piece of colour: + the dawn-arc gradient (charred umber → steel blue → cream). It draws the + hero, the brand mark, the verification band, the equity curve. Everything + else is achromatic. Elevation comes from lightness and a hairline, never a + drop shadow. + + Two deliberate deviations from DESIGN.md, both on purpose: + + 1. No webfonts. DESIGN.md names Fraunces and Hanken Grotesk; the whole + pitch of this product is that it keeps working when nobody else's server + answers, and that should be true of the page too. The three type *roles* + are kept — display serif, UI sans, mono for hashes — on system stacks. + Swap --font-display / --font-sans if you ever self-host the real pair. + 2. Container is 1100px, not 1400px. This app is one column of prose and + evidence; 1400 would set 160-character lines. */ + +:root { + /* Raw palette ------------------------------------------------------- */ + --color-onyx: #14181f; + --color-onyx-soft: #2b323c; + --color-slate-veil: #616a77; + --color-porcelain: #fbfaf7; + --color-paper: #ffffff; + --color-bone: #f4f2ed; + --color-umber: #6d4030; + --color-steel: #47657f; + --color-cream: #efe2ca; + + /* Semantic aliases -------------------------------------------------- */ + --surface-page: var(--color-porcelain); + --surface-card: var(--color-paper); + --surface-inset: var(--color-bone); + --surface-deep: var(--color-onyx); + + --text-body: var(--color-onyx); + --text-dim: var(--color-slate-veil); + --text-invert: #f7f4ee; + + --hairline: rgba(20, 24, 31, 0.12); + --hairline-soft: rgba(20, 24, 31, 0.07); + --hairline-strong: rgba(20, 24, 31, 0.26); + + --gradient-dawn-arc: linear-gradient( + 101deg, + var(--color-umber) 0%, + var(--color-steel) 54%, + var(--color-cream) 100% + ); + /* Same arc, stopped before cream — for anything drawn on a white ground, + where the cream end would simply disappear. */ + --gradient-dawn-arc-ink: linear-gradient( + 96deg, + var(--color-umber) 0%, + var(--color-steel) 100% + ); + + --radius-card: 16px; + --radius-tile: 12px; + --radius-pill: 50px; + + --font-display: "Iowan Old Style", "Palatino Linotype", Palatino, "Hoefler Text", + Georgia, serif; + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Helvetica, Arial, + sans-serif; + --font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace; + + --shell-max: 1100px; + --step: 0.15s ease; + + /* Legacy compatibility ----------------------------------------------- + Older variable names, remapped onto New Genre so the inline styles + scattered through the components stay coherent. The four trust tiers + resolve to points on the dawn arc rather than to green/blue/amber/red: + the hierarchy reads onyx → steel → slate → umber, strongest to weakest, + with no traffic-light semantics anywhere. */ + --ink: var(--surface-page); + --surface: var(--surface-card); + --surface-2: var(--surface-inset); + --line: var(--hairline); + --text: var(--text-body); + --muted: var(--text-dim); + --accent: var(--color-steel); + --radius: var(--radius-card); + --serif: var(--font-display); + --sans: var(--font-sans); + --mono: var(--font-mono); + + --tier-source_signed: var(--color-onyx); + --tier-aggregator_attested: var(--color-steel); + --tier-platform_observed: var(--color-slate-veil); + --tier-self_reported: var(--color-umber); +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + min-height: 100%; +} + +body { + background: var(--surface-page); + color: var(--text-body); + font-family: var(--font-sans); + font-size: 15px; + line-height: 1.6; + letter-spacing: -0.01em; + -webkit-font-smoothing: antialiased; +} + +a { + color: inherit; + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-color: var(--hairline-strong); + transition: text-decoration-color var(--step); +} +a:hover { + text-decoration-color: currentColor; +} + +h1, +h2, +h3 { + font-family: var(--font-display); + font-weight: 500; + letter-spacing: -0.02em; + margin: 0; +} +h1 { + font-size: clamp(2rem, 3.4vw, 2.6rem); + line-height: 1.1; +} +h2 { + font-size: 1.42rem; + line-height: 1.2; +} +h3 { + font-size: 1.08rem; +} + +p { + margin: 0 0 14px; +} +p:last-child { + margin-bottom: 0; +} + +code, +.mono { + font-family: var(--font-mono); + font-size: 0.85em; + letter-spacing: 0; +} + +.muted { + color: var(--text-dim); +} +.small { + font-size: 0.82rem; +} + +.row { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} +.spread { + justify-content: space-between; +} + +/* Page frame ---------------------------------------------------------- */ + +.page { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.shell { + width: 100%; + max-width: var(--shell-max); + margin: 0 auto; + padding: 40px 24px 88px; + flex: 1; +} + +/* Sticky frosted header. */ +.masthead { + position: sticky; + top: 0; + z-index: 40; + background: rgba(251, 250, 247, 0.76); + backdrop-filter: blur(14px) saturate(1.4); + -webkit-backdrop-filter: blur(14px) saturate(1.4); + border-bottom: 1px solid var(--hairline); +} +.masthead-inner { + max-width: var(--shell-max); + margin: 0 auto; + padding: 15px 24px; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} +.wordmark { + font-family: var(--font-display); + font-size: 1.24rem; + letter-spacing: -0.02em; + text-decoration: none; +} +/* The brand mark is drawn by the dawn arc. */ +.wordmark span { + background: var(--gradient-dawn-arc-ink); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.masthead .tagline { + color: var(--text-dim); + font-size: 0.83rem; +} + +/* Full-bleed hero. The arc runs left-to-right into cream, so the copy is + held to the umber/steel half and given a scrim — contrast is not left to + chance. */ +.hero { + background-image: linear-gradient( + to right, + rgba(10, 12, 16, 0.34) 0%, + rgba(10, 12, 16, 0) 64% + ), + var(--gradient-dawn-arc); + color: #fbf7f0; + padding: 74px 24px 82px; +} +.hero-inner { + max-width: var(--shell-max); + margin: 0 auto; +} +.hero h1 { + font-size: clamp(2.3rem, 5.2vw, 3.7rem); + color: inherit; + max-width: 19ch; + font-weight: 400; +} +.hero-lede { + margin: 22px 0 0; + max-width: 60ch; + font-size: 1.04rem; + line-height: 1.62; + color: rgba(251, 247, 240, 0.86); +} + +/* Two ways in, one loud and one quiet: look at a record, or take a seat. */ +.hero-cta { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-top: 30px; +} +.cta { + display: inline-block; + background: var(--color-porcelain); + color: var(--color-onyx); + border-radius: var(--radius-pill); + padding: 13px 28px; + font-weight: 500; + font-size: 0.98rem; + text-decoration: none; + transition: transform var(--step), background var(--step), border-color var(--step); +} +.cta:hover { + background: #fff; + transform: translateX(2px); +} +.cta-ghost { + background: transparent; + color: #fbf7f0; + border: 1px solid rgba(251, 247, 240, 0.42); +} +.cta-ghost:hover { + background: rgba(251, 247, 240, 0.12); + border-color: rgba(251, 247, 240, 0.78); +} +.cta-note { + display: block; + max-width: 44ch; + margin: 16px 0 0; + font-size: 0.8rem; + line-height: 1.5; + color: rgba(251, 247, 240, 0.68); +} + +/* Dark footer, bookending the arc. */ +.site-footer { + background: var(--surface-deep); + color: rgba(247, 244, 238, 0.66); +} +.site-footer::before { + content: ""; + display: block; + height: 3px; + background: var(--gradient-dawn-arc); +} +.footer-inner { + max-width: var(--shell-max); + margin: 0 auto; + padding: 46px 24px 58px; + display: flex; + justify-content: space-between; + gap: 28px; + flex-wrap: wrap; +} +.site-footer .footer-mark { + font-family: var(--font-display); + font-size: 1.5rem; + line-height: 1.15; + letter-spacing: -0.02em; + color: var(--text-invert); +} +/* Same dawn arc that draws the mark in the masthead, bookending the page. */ +.site-footer .footer-mark span { + background: var(--gradient-dawn-arc); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.site-footer a { + color: var(--text-invert); + text-decoration-color: rgba(247, 244, 238, 0.32); +} +.footer-meta { + font-size: 0.82rem; + text-align: right; + max-width: 46ch; +} + +/* Cards --------------------------------------------------------------- */ + +.card { + background: var(--surface-card); + border: 1px solid var(--hairline); + border-radius: var(--radius-card); + padding: 26px 28px; + margin-bottom: 20px; +} +.card-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 4px; +} +.card > h2, +.card > h3 { + margin-bottom: 4px; +} +.card .lede { + color: var(--text-dim); + font-size: 0.9rem; + margin: 0 0 18px; + max-width: 74ch; +} + +/* The dawn-arc accent band marks the one card that carries the proof. */ +.card-accent { + position: relative; + overflow: hidden; +} +.card-accent::before { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 3px; + background: var(--gradient-dawn-arc); +} + +.record-head { + padding-bottom: 24px; + margin-bottom: 28px; + border-bottom: 1px solid var(--hairline); +} +.record-head::before { + content: ""; + display: block; + width: 66px; + height: 3px; + border-radius: 2px; + background: var(--gradient-dawn-arc); + margin-bottom: 20px; +} +.record-head p { + margin: 8px 0 0; +} + +/* Pills, badges ------------------------------------------------------- */ + +.pill { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 0.7rem; + letter-spacing: 0.02em; + color: var(--text-dim); + background: var(--surface-inset); + border-radius: var(--radius-pill); + padding: 4px 11px; + white-space: nowrap; +} + +/* Badges encode the trust hierarchy by weight, not by hue: + solid fill > filled outline > quiet outline > greyed. */ +.badge { + display: inline-flex; + align-items: center; + gap: 7px; + border-radius: var(--radius-pill); + padding: 4px 13px; + font-family: var(--font-mono); + font-size: 0.71rem; + letter-spacing: 0.04em; + text-transform: uppercase; + border: 1px solid transparent; + white-space: nowrap; +} +.badge .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} +.badge-source_signed { + background: var(--color-onyx); + color: var(--text-invert); +} +.badge-aggregator_attested { + color: var(--color-steel); + border-color: currentColor; +} +.badge-platform_observed { + color: var(--text-dim); + border-color: var(--hairline-strong); +} +.badge-platform_observed .dot, +.badge-self_reported .dot { + background: transparent; + border: 1.5px solid currentColor; +} +.badge-self_reported { + background: var(--surface-inset); + color: var(--text-dim); + border-color: var(--hairline-soft); +} + +/* Metric tiles -------------------------------------------------------- */ + +.grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(184px, 1fr)); +} + +.metric-tile { + background: var(--surface-inset); + border: 1px solid var(--hairline-soft); + border-radius: var(--radius-tile); + padding: 15px 17px; +} +.metric-tile .label { + color: var(--text-dim); + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.09em; +} +.metric-tile .value { + font-size: 1.62rem; + font-weight: 500; + letter-spacing: -0.03em; + margin-top: 6px; + font-variant-numeric: tabular-nums; + line-height: 1.1; +} +/* Gain and loss are read from the sign, not from colour. Both resolve to + Onyx on purpose — the + or − does the work. */ +.metric-tile .value.pos, +.metric-tile .value.neg { + color: var(--text-body); +} +.metric-tile .sub { + color: var(--text-dim); + font-size: 0.7rem; + margin-top: 6px; + font-family: var(--font-mono); + letter-spacing: 0.02em; +} + +/* Hashlines — key/value rows for cryptographic values. */ +.hashline { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 20px; + padding: 11px 0; + border-bottom: 1px solid var(--hairline-soft); +} +.hashline:last-child { + border-bottom: none; +} +.hashline .k { + color: var(--text-dim); + font-size: 0.82rem; + white-space: nowrap; +} +.hashline .v { + font-family: var(--font-mono); + font-size: 0.78rem; + text-align: right; + word-break: break-all; +} + +/* Tables -------------------------------------------------------------- */ + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.86rem; +} +th { + text-align: left; + color: var(--text-dim); + font-weight: 500; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.09em; + padding: 9px 12px; + border-bottom: 1px solid var(--hairline); +} +td { + padding: 12px; + border-bottom: 1px solid var(--hairline-soft); + vertical-align: top; +} +tr:last-child td { + border-bottom: none; +} +td.num, +th.num { + font-family: var(--font-mono); + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* Controls ------------------------------------------------------------ */ + +button { + font: inherit; + font-size: 0.87rem; + font-weight: 500; + letter-spacing: -0.01em; + color: var(--text-invert); + background: var(--color-onyx); + border: 1px solid var(--color-onyx); + border-radius: var(--radius-pill); + padding: 9px 20px; + cursor: pointer; + transition: background var(--step), border-color var(--step), opacity var(--step); +} +button:hover:not(:disabled) { + background: var(--color-onyx-soft); + border-color: var(--color-onyx-soft); +} +button:disabled { + opacity: 0.4; + cursor: not-allowed; +} +button.ghost { + background: transparent; + color: var(--text-body); + border-color: var(--hairline-strong); +} +button.ghost:hover:not(:disabled) { + background: var(--surface-inset); + border-color: var(--hairline-strong); +} + +input[type="text"] { + font-family: var(--font-mono); + font-size: 0.8rem; + background: var(--surface-inset); + border: 1px solid var(--hairline); + border-radius: var(--radius-pill); + color: var(--text-body); + padding: 9px 16px; + flex: 1; + min-width: 240px; + transition: border-color var(--step), background var(--step); +} +input[type="text"]:focus { + outline: none; + background: var(--surface-card); + border-color: var(--hairline-strong); +} + +/* Verification checks ------------------------------------------------- */ + +.check { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 12px 0; + border-bottom: 1px solid var(--hairline-soft); +} +.check:last-child { + border-bottom: none; +} +.check .mark { + font-family: var(--font-mono); + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.09em; + padding: 3px 10px; + border-radius: var(--radius-pill); + border: 1px solid transparent; + flex-shrink: 0; + margin-top: 2px; +} +.mark.ok { + color: var(--text-invert); + background: var(--color-onyx); +} +.mark.bad { + color: var(--text-invert); + background: var(--color-umber); +} +.mark.wait { + color: var(--text-dim); + border-color: var(--hairline-strong); +} + +.verdict { + margin: 18px 0 0; + padding-top: 16px; + border-top: 1px solid var(--hairline); + font-family: var(--font-display); + font-size: 1.15rem; + letter-spacing: -0.02em; +} + +/* Findings, banners --------------------------------------------------- */ + +.finding { + border-left: 2px solid var(--hairline-strong); + padding: 6px 0 6px 15px; + margin-bottom: 14px; +} +.finding:last-child { + margin-bottom: 0; +} +.finding.critical { + border-left-color: var(--color-umber); +} +.finding .kind { + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.03em; + color: var(--text-dim); + text-transform: uppercase; +} + +.banner { + position: relative; + background: var(--surface-inset); + border: 1px solid var(--hairline); + border-radius: var(--radius-card); + padding: 14px 18px 14px 22px; + font-size: 0.86rem; + color: var(--text-dim); + margin-bottom: 24px; + overflow: hidden; +} +.banner::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--gradient-dawn-arc); +} +.banner strong { + color: var(--text-body); + font-weight: 500; +} + +/* Record list --------------------------------------------------------- */ + +.record-link { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 20px; + border: 1px solid var(--hairline); + border-radius: var(--radius-tile); + background: var(--surface-card); + padding: 17px 20px; + margin-bottom: 10px; + color: inherit; + text-decoration: none; + transition: background var(--step), border-color var(--step); +} +.record-link:last-child { + margin-bottom: 0; +} +.record-link:hover { + background: var(--surface-inset); + border-color: var(--hairline-strong); +} +.record-link .name { + font-family: var(--font-display); + font-size: 1.14rem; + letter-spacing: -0.02em; +} +.record-link .arrow { + font-family: var(--font-mono); + color: var(--text-dim); + flex-shrink: 0; +} + +/* Chart --------------------------------------------------------------- */ + +.chart-wrap svg { + display: block; + width: 100%; + height: auto; +} +.chart-note { + color: var(--text-dim); + font-size: 0.8rem; + margin-top: 10px; + max-width: 74ch; +} + +/* The manager's side -------------------------------------------------- + Same system, one signal of difference: an onyx rail under the masthead. + A manager glancing at a screenshot should know in a quarter second whether + they are looking at their own view or at what they published. */ + +.owner-rail { + background: var(--surface-deep); + color: rgba(247, 244, 238, 0.72); +} +.owner-rail-inner { + max-width: var(--shell-max); + margin: 0 auto; + padding: 9px 24px; + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.03em; +} +.page-owner .masthead { + border-bottom-color: transparent; +} + +/* One rail per seat. The fund's is onyx, the PM's is steel — a screenshot + should say whose eyes you are looking through before you read a word. */ +.rail-pm { + background: var(--color-steel); + color: rgba(255, 255, 255, 0.86); +} +.owner-rail strong { + letter-spacing: 0.14em; + color: #fff; +} +.seat-pill { + border-color: var(--color-onyx); + color: var(--color-onyx); +} +.seat-pill.seat-pm { + border-color: var(--color-steel); + color: var(--color-steel); +} + +/* Sign-in: one button per seat, and the buttons carry the argument. */ +.seat-button { + display: grid; + grid-template-columns: 46px 1fr auto; + align-items: baseline; + gap: 12px; + width: 100%; + text-align: left; + background: var(--surface-card); + color: inherit; + border: 1px solid var(--hairline); + border-left: 3px solid var(--color-steel); + border-radius: var(--radius-tile); + padding: 12px 16px; + margin-bottom: 8px; + cursor: pointer; + transition: background var(--step), border-color var(--step); +} +.seat-button.seat-fund { + border-left-color: var(--color-onyx); +} +.seat-button:hover { + background: var(--surface-inset); + border-color: var(--hairline-strong); +} +.seat-button:hover.seat-fund { + border-left-color: var(--color-onyx); +} +.seat-role { + font-family: var(--font-mono); + font-size: 0.66rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-dim); +} +.seat-name { + font-weight: 500; +} +.seat-scope { + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--text-dim); +} + +.creds { + background: var(--surface-inset); + border: 1px solid var(--hairline-soft); + border-radius: var(--radius-tile); + padding: 16px 18px; +} + +/* Doors on the landing page. Three seats, three left-edge marks: the fund is + onyx, the PM is steel, the allocator is unmarked because they are outside the + wall and that is the point. */ +.three-doors { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; + margin-bottom: 20px; +} +.door-fund, +.door-pm { + border-left: 3px solid var(--color-onyx); +} +.door-pm { + border-left-color: var(--color-steel); +} +.door-allocator { + border-left: 3px dashed var(--hairline-strong); +} +.door { + position: relative; + display: block; + background: var(--surface-card); + border: 1px solid var(--hairline); + border-radius: var(--radius-card); + padding: 22px 24px 24px; + color: inherit; + text-decoration: none; + overflow: hidden; + transition: background var(--step), border-color var(--step); +} +.door::before { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 3px; + background: var(--gradient-dawn-arc); + opacity: 0; + transition: opacity var(--step); +} +.door:hover { + background: var(--surface-inset); + border-color: var(--hairline-strong); +} +.door:hover::before { + opacity: 1; +} +.door-role { + font-family: var(--font-mono); + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.11em; + color: var(--text-dim); +} +.door-name { + font-family: var(--font-display); + font-size: 1.5rem; + letter-spacing: -0.02em; + margin: 6px 0 8px; +} +.door p { + max-width: 40ch; +} +.door .arrow { + position: absolute; + top: 22px; + right: 24px; + font-family: var(--font-mono); + color: var(--text-dim); +} + +/* The allocator's half of the disclosure contract. */ +.profile-strip { + background: var(--surface-inset); + border: 1px solid var(--hairline-soft); + border-radius: var(--radius-tile); + padding: 14px 18px; + margin-bottom: 20px; +} +.profile-strip > div { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 6px; +} +.profile-strip .k { + color: var(--text-dim); + font-size: 0.82rem; +} +.profile-strip p { + max-width: 78ch; +} + +.pill-on { + background: var(--color-onyx); + color: var(--text-invert); +} +.pill-absent { + background: transparent; + border: 1px dashed var(--hairline-strong); + color: var(--text-dim); +} + +/* Disclosure matrix. Columns are clickable; the live one is filled onyx. */ +.matrix-wrap { + overflow-x: auto; +} +.matrix { + min-width: 560px; +} +.matrix th:first-child, +.matrix td:first-child { + text-align: left; + white-space: nowrap; +} +.matrix .matrix-col { + cursor: pointer; + text-align: center; + transition: background var(--step), color var(--step); +} +.matrix th.matrix-col { + font-family: var(--font-mono); + font-size: 0.66rem; +} +.matrix .matrix-col:hover { + background: var(--surface-inset); +} +.matrix .matrix-col.on { + background: var(--color-onyx); + color: var(--text-invert); +} +.matrix th.matrix-col.on { + border-bottom-color: var(--color-onyx); +} +.matrix-bytes td { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} +.cell-yes { + font-variant-numeric: tabular-nums; +} +.cell-no { + color: var(--text-dim); + opacity: 0.55; +} +.matrix .matrix-col.on .cell-no { + color: var(--text-invert); +} + +/* The allocator payload, rendered inside a frame so nobody mistakes it for + the manager's own numbers. */ +.preview-frame { + border: 1px solid var(--hairline); + border-radius: var(--radius-tile); + overflow: hidden; + background: var(--surface-page); +} +.preview-bar { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 9px 14px; + background: var(--surface-inset); + border-bottom: 1px solid var(--hairline); + color: var(--text-dim); +} +.preview-body { + padding: 20px 18px 22px; +} + +/* Invites. */ +.invite-form { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + margin-bottom: 4px; +} +select { + font: inherit; + font-size: 0.84rem; + background: var(--surface-inset); + border: 1px solid var(--hairline); + border-radius: var(--radius-pill); + color: var(--text-body); + padding: 9px 16px; + cursor: pointer; +} +.invite-issued { + margin-top: 18px; + padding: 16px 18px; + background: var(--surface-inset); + border: 1px solid var(--hairline-soft); + border-radius: var(--radius-tile); +} +button.small-btn { + font-size: 0.76rem; + padding: 5px 14px; +} + +.status { + font-family: var(--font-mono); + font-size: 0.7rem; + letter-spacing: 0.04em; + text-transform: uppercase; + border-radius: var(--radius-pill); + padding: 3px 11px; + border: 1px solid transparent; +} +.status-active { + background: var(--color-onyx); + color: var(--text-invert); +} +.status-revoked { + color: var(--color-umber); + border-color: currentColor; +} +.status-expired { + color: var(--text-dim); + border-color: var(--hairline-strong); +} + +/* Landing page --------------------------------------------------------- + + The public page is the only surface here that has to sell rather than + report, so it is the only one that moves: sections arrive as you reach + them, the pipeline's connectors run, and the hero card checks a signature + on a loop. All of it is decoration laid over a page that reads perfectly + well standing still, which is exactly why prefers-reduced-motion turns + every bit of it off at the bottom of this block. Motion that carried + information could not be switched off like that. */ + +.reveal { + opacity: 0; + transform: translateY(16px); + transition: + opacity 0.6s cubic-bezier(0.22, 0.7, 0.3, 1), + transform 0.6s cubic-bezier(0.22, 0.7, 0.3, 1); + transition-delay: var(--d, 0s); + will-change: opacity, transform; +} +.reveal.in { + opacity: 1; + transform: none; +} + +.section { + margin-bottom: 66px; +} +.section:last-of-type { + margin-bottom: 40px; +} +.section-head { + margin-bottom: 24px; +} +.eyebrow { + display: flex; + align-items: center; + gap: 12px; + font-family: var(--font-mono); + font-size: 0.67rem; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--text-dim); +} +.eyebrow::after { + content: ""; + flex: 1; + height: 1px; + background: var(--hairline); +} +.section-head h2 { + margin-top: 12px; + font-size: clamp(1.55rem, 2.7vw, 2.05rem); + max-width: 24ch; +} + +/* Hero: copy on the umber half where the scrim is, the animated card on the + cream half where a dark panel reads best. */ +.hero-grid { + display: grid; + grid-template-columns: 1.1fr 0.9fr; + gap: 52px; + align-items: center; +} + +.proof-card { + background: rgba(16, 19, 25, 0.6); + backdrop-filter: blur(12px) saturate(1.2); + -webkit-backdrop-filter: blur(12px) saturate(1.2); + border: 1px solid rgba(255, 255, 255, 0.17); + border-radius: var(--radius-card); + padding: 17px 19px 19px; + color: rgba(251, 247, 240, 0.94); +} +.proof-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 14px; + margin-bottom: 12px; +} +.proof-head .mono { + font-size: 0.74rem; +} +.proof-from { + font-size: 0.7rem; + color: rgba(251, 247, 240, 0.55); +} +.proof-sig { + position: relative; + overflow: hidden; + font-size: 0.63rem; + line-height: 1.55; + color: rgba(251, 247, 240, 0.5); + border: 1px dashed rgba(255, 255, 255, 0.2); + border-radius: 9px; + padding: 8px 10px; + word-break: break-all; +} +/* The scan: a band of cream sweeps the signature, then the verdict lands. */ +.proof-sig::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: -45%; + width: 45%; + background: linear-gradient( + 90deg, + transparent, + rgba(239, 226, 202, 0.42), + transparent + ); + animation: scan 7s ease-in-out infinite; +} +@keyframes scan { + 0% { + transform: translateX(0); + opacity: 0; + } + 5% { + opacity: 1; + } + 30% { + transform: translateX(330%); + opacity: 1; + } + 36%, + 100% { + transform: translateX(330%); + opacity: 0; + } +} +.proof-rows { + margin: 13px 0 14px; +} +.proof-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; + padding: 7px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.11); + font-size: 0.8rem; + color: rgba(251, 247, 240, 0.62); +} +.proof-row:last-child { + border-bottom: none; +} +.proof-row b { + font-family: var(--font-mono); + font-weight: 500; + font-variant-numeric: tabular-nums; + color: rgba(251, 247, 240, 0.96); +} +/* Both verdicts occupy the same cell so the swap has nothing to reflow. */ +.proof-verdict { + display: grid; + min-height: 26px; + align-items: center; +} +.proof-verdict > * { + grid-area: 1 / 1; +} +.proof-wait { + font-size: 0.66rem; + letter-spacing: 0.02em; + color: rgba(251, 247, 240, 0.5); + animation: waitOut 7s ease-in-out infinite; +} +.proof-ok { + display: inline-flex; + align-items: center; + gap: 8px; + justify-self: start; + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-onyx); + background: var(--color-cream); + border-radius: var(--radius-pill); + padding: 5px 14px; + animation: okIn 7s ease-in-out infinite; +} +.proof-tick { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} +@keyframes waitOut { + 0%, + 33% { + opacity: 1; + } + 39%, + 100% { + opacity: 0; + } +} +@keyframes okIn { + 0%, + 35% { + opacity: 0; + transform: translateY(5px); + } + 45%, + 93% { + opacity: 1; + transform: none; + } + 100% { + opacity: 0; + transform: none; + } +} + +/* The three options. Two fail, and they say so with a mark rather than a + paragraph explaining that they failed. */ +.opts { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; +} +.opt { + position: relative; + overflow: hidden; + background: var(--surface-card); + border: 1px solid var(--hairline); + border-radius: var(--radius-card); + padding: 22px 24px 24px; +} +.opt h3 { + margin: 16px 0 6px; + font-size: 1.14rem; +} +.opt p { + margin: 0; +} +.opt-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 50%; + border: 1px solid var(--hairline-strong); + color: var(--text-dim); + font-size: 0.94rem; +} +.opt-mark-on { + background: var(--color-onyx); + border-color: var(--color-onyx); + color: var(--text-invert); +} +/* The two that fail look like they failed: no fill, a dashed edge, and a + heading that has given up half its ink. */ +.opt-bad { + background: transparent; + border-style: dashed; +} +.opt-bad h3 { + color: var(--text-dim); +} +.opt-good { + border-color: var(--hairline-strong); +} +.opt-good::before { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 3px; + background: var(--gradient-dawn-arc); +} + +/* The pipeline. Connectors are marching ants in the gutter, which is the one + place on this page where the motion is doing a little work: it says which + way the evidence travels. */ +.flow { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 30px; + list-style: none; + margin: 0; + padding: 0; +} +.flow-step { + position: relative; +} +.flow-step::after { + content: ""; + position: absolute; + top: 22px; + right: -30px; + width: 30px; + height: 2px; + background-image: linear-gradient( + 90deg, + var(--hairline-strong) 0 7px, + transparent 7px 13px + ); + background-size: 13px 2px; + animation: march 0.85s linear infinite; +} +.flow-step:last-child::after { + display: none; +} +@keyframes march { + to { + background-position: 13px 0; + } +} +.flow-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 46px; + height: 46px; + border-radius: 50%; + border: 1px solid var(--hairline); + background: var(--surface-card); + color: var(--color-onyx); +} +.flow-icon svg { + width: 22px; + height: 22px; +} +.flow-n { + font-family: var(--font-mono); + font-size: 0.67rem; + letter-spacing: 0.14em; + color: var(--text-dim); + margin-top: 14px; +} +.flow-step h3 { + margin: 5px 0 7px; + font-size: 1.02rem; +} +.flow-step p { + margin: 0; +} + +/* Same number, different evidence. The whole argument in one glance. */ +.versus { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} +.vs-card { + display: block; + background: var(--surface-card); + border: 1px solid var(--hairline); + border-radius: var(--radius-card); + padding: 22px 24px 24px; + color: inherit; + text-decoration: none; + transition: background var(--step), border-color var(--step); +} +.vs-card:hover { + background: var(--surface-inset); + border-color: var(--hairline-strong); +} +.vs-name { + font-family: var(--font-mono); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-dim); +} +.vs-value { + font-family: var(--font-display); + font-size: clamp(2.2rem, 4.4vw, 3rem); + line-height: 1.05; + letter-spacing: -0.03em; + font-variant-numeric: tabular-nums; + margin: 10px 0 4px; +} +.vs-sub { + font-size: 0.8rem; + color: var(--text-dim); + margin-bottom: 18px; +} +.vs-foot { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-top: 16px; +} +.vs-caption { + max-width: 76ch; + margin: 16px 0 0; +} + +/* Twelve months as twelve marks. Solid is a signature; the dashed one is + somebody's keyboard, and it keeps breathing after the rest have settled. */ +.ticks { + display: flex; + gap: 5px; +} +.tick { + flex: 1; + height: 24px; + border-radius: 4px; + background: var(--color-onyx); + transform: scaleY(0.2); + transform-origin: bottom; + opacity: 0; +} +.reveal.in .tick { + animation: tickIn 0.42s cubic-bezier(0.22, 0.7, 0.3, 1) forwards; + animation-delay: calc(var(--i) * 42ms + 120ms); +} +.tick.typed { + background: transparent; + border: 1.5px dashed var(--color-umber); +} +.reveal.in .tick.typed { + animation: + tickIn 0.42s cubic-bezier(0.22, 0.7, 0.3, 1) forwards, + breathe 2.4s ease-in-out 1s infinite; +} +@keyframes tickIn { + to { + transform: none; + opacity: 1; + } +} +@keyframes breathe { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.36; + } +} + +@media (max-width: 900px) { + .hero-grid, + .three-doors, + .opts, + .versus { + grid-template-columns: 1fr; + } + .hero-grid { + gap: 34px; + } + .flow { + grid-template-columns: 1fr 1fr; + gap: 20px; + } + .flow-step::after { + display: none; + } +} + +@media (max-width: 620px) { + .flow { + grid-template-columns: 1fr; + } +} + +/* Everything above is theatre. This turns all of it off without changing a + word of what the page says. */ +@media (prefers-reduced-motion: reduce) { + .reveal { + opacity: 1; + transform: none; + transition: none; + } + .proof-sig::after, + .proof-wait, + .proof-ok, + .flow-step::after, + .reveal.in .tick, + .reveal.in .tick.typed { + animation: none; + } + .proof-wait { + opacity: 0; + } + .proof-ok, + .tick { + opacity: 1; + transform: none; + } +} + +/* Per-viewer watermark ------------------------------------------------ */ + +.watermark { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +.watermark span { + font-family: var(--font-mono); + font-size: 1.5rem; + color: rgba(20, 24, 31, 0.045); + transform: rotate(-28deg); + white-space: nowrap; + line-height: 3.2; + text-align: center; +} + +@media (max-width: 720px) { + .hero { + padding: 52px 22px 56px; + } + .card { + padding: 22px 20px; + } + .footer-meta { + text-align: left; + } +} diff --git a/web/src/verify.ts b/web/src/verify.ts new file mode 100644 index 0000000..e91c287 --- /dev/null +++ b/web/src/verify.ts @@ -0,0 +1,138 @@ +/** + * Verification that runs in the browser, not on our server. + * + * The Merkle fold below has to agree with `core/merkle.py` byte for byte. Two + * details carry that: internal nodes are hashed under a 0x01 prefix so a node + * can never be passed off as a leaf, and an odd node is promoted rather than + * hashed against a copy of itself. If this file and the Python ever disagree, + * one of them is wrong and the UI should say so rather than paper over it. + * + * The chain read goes through whatever RPC the viewer chooses. That is the + * point of the exercise: our API is not in the trust path, and a "verified" + * badge that we computed would be worth nothing. + */ + +import { createPublicClient, defineChain, http, type Address, type Hex } from "viem"; + +export type ProofStep = { sibling: string; side: "left" | "right" }; +export type MerkleProof = { leaf: string; steps: ProofStep[]; root?: string }; + +const NODE_PREFIX = 0x01; + +function fromHex(hex: string): Uint8Array { + const clean = hex.replace(/^0x/, ""); + const out = new Uint8Array(clean.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +async function sha256(data: Uint8Array): Promise { + const digest = await crypto.subtle.digest("SHA-256", data as unknown as BufferSource); + return new Uint8Array(digest); +} + +async function hashNode(left: Uint8Array, right: Uint8Array): Promise { + const buffer = new Uint8Array(1 + left.length + right.length); + buffer[0] = NODE_PREFIX; + buffer.set(left, 1); + buffer.set(right, 1 + left.length); + return sha256(buffer); +} + +/** Re-fold one leaf to a root. Pure, local, and the only thing behind a + * "verified locally" badge. */ +export async function foldProof(proof: MerkleProof): Promise { + let current = fromHex(proof.leaf); + for (const step of proof.steps) { + const sibling = fromHex(step.sibling); + current = + step.side === "right" + ? await hashNode(current, sibling) + : await hashNode(sibling, current); + } + return "0x" + toHex(current); +} + +export async function verifyProof( + proof: MerkleProof, + expectedRoot: string, +): Promise { + const folded = await foldProof(proof); + return folded.toLowerCase() === expectedRoot.toLowerCase(); +} + +/** Rebuild the whole tree from every leaf, and check it gives the same root. + * Stronger than checking one path: it proves the published leaf set is the + * set that was actually committed, with nothing added or dropped. */ +export async function rebuildRoot(leafDigests: string[]): Promise { + let level = leafDigests + .map((hex) => fromHex(hex)) + .sort((a, b) => { + for (let i = 0; i < Math.min(a.length, b.length); i++) { + if (a[i] !== b[i]) return a[i] - b[i]; + } + return a.length - b.length; + }); + + if (level.length === 0) throw new Error("no leaves"); + + while (level.length > 1) { + const next: Uint8Array[] = []; + for (let i = 0; i + 1 < level.length; i += 2) { + next.push(await hashNode(level[i], level[i + 1])); + } + if (level.length % 2 === 1) next.push(level[level.length - 1]); // promoted + level = next; + } + return "0x" + toHex(level[0]); +} + +// -- the chain ------------------------------------------------------------ + +const HEAD_ABI = [ + { + type: "function", + name: "head", + stateMutability: "view", + inputs: [{ name: "recordId", type: "bytes32" }], + outputs: [ + { name: "root", type: "bytes32" }, + { name: "seq", type: "uint64" }, + { name: "ts", type: "uint64" }, + ], + }, +] as const; + +export type ChainHead = { root: string; seq: bigint; ts: bigint }; + +export async function readAnchoredRoot(options: { + rpcUrl: string; + chainId: number; + registry: string; + recordKey: string; +}): Promise { + const chain = defineChain({ + id: options.chainId, + name: "Monad", + nativeCurrency: { name: "Monad", symbol: "MON", decimals: 18 }, + rpcUrls: { default: { http: [options.rpcUrl] } }, + }); + + const client = createPublicClient({ chain, transport: http(options.rpcUrl) }); + const [root, seq, ts] = await client.readContract({ + address: options.registry as Address, + abi: HEAD_ABI, + functionName: "head", + args: [options.recordKey as Hex], + }); + + return { root: root as string, seq: seq as bigint, ts: ts as bigint }; +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..29c6120 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": false, + "noEmit": true + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..8bc4349 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + "/api": "http://localhost:8000", + }, + }, + build: { + outDir: "dist", + emptyOutDir: true, + }, +}); diff --git a/worker/__init__.py b/worker/__init__.py new file mode 100644 index 0000000..39e4e57 --- /dev/null +++ b/worker/__init__.py @@ -0,0 +1 @@ +"""The ingest pipeline.""" diff --git a/worker/pipeline.py b/worker/pipeline.py new file mode 100644 index 0000000..528157a --- /dev/null +++ b/worker/pipeline.py @@ -0,0 +1,611 @@ +"""The ingest pipeline: seven numbered steps, each pure where it can be. + +Receive · Verify · Extract · Normalize · Reconcile · Compute · Commit. + +It runs inline rather than on a queue. A single message takes milliseconds to +verify and parse, and anchoring on Monad confirms in well under a second, so a +worker and a broker would add two moving parts and a class of "the job is +somewhere" bugs in exchange for nothing a user would notice. The orchestrator +below is a plain function, so putting it behind a queue later is a change of +caller, not a rewrite. +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Sequence + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from adapters import db +from adapters.blobs import blob_store +from adapters.chain import chain_client, record_key +from adapters.config import settings +from adapters.dns import ChainResolver, DnsResolver, LiveResolver, StaticResolver +from core import METHODOLOGY_VERSION +from core.dkim import DkimAnalysis, required_dns_names, verify_message +from core.extraction import registry +from core.extractors import INSTITUTIONS +from core.metrics import Config, InsufficientSeries, Uncomputable, build_series, compute +from core.reconcile import reconcile +from core.snapshot import Snapshot, build_snapshot +from core.tiers import Tier +from core.types import ( + Channel, + DkimVerdict, + DocType, + Document, + Fact, + FactKind, + Finding, + Institution, + MetricResult, + Severity, + tier_for, +) + + +def active_institutions() -> tuple[Institution, ...]: + """The DKIM allow-list actually in force. + + The demo domain is bolted on here, at the edge, and only when the fixture + flag is set — never inside `core.extractors`, so a production deployment + cannot inherit a test domain by accident. + """ + if settings().demo_fixtures: + from demo.corpus import demo_institutions + + return demo_institutions() + return INSTITUTIONS + + +def default_resolver() -> DnsResolver: + """Live DNS, falling back to whatever the fixture corpus captured.""" + if settings().demo_fixtures: + return ChainResolver(LiveResolver(), StaticResolver(_fixture_dns())) + return LiveResolver() + + +_FIXTURE_DNS: dict[str, str] | None = None + + +def _fixture_dns() -> dict[str, str]: + global _FIXTURE_DNS + if _FIXTURE_DNS is None: + from demo.corpus import load_or_build + + _FIXTURE_DNS = load_or_build(settings().fixture_dir).captured_dns + return _FIXTURE_DNS + + +def set_fixture_dns(records: dict[str, str]) -> None: + """The seed script generates one corpus and shares its keys with ingest.""" + global _FIXTURE_DNS + _FIXTURE_DNS = dict(records) + + +@dataclass +class IngestOutcome: + document_id: str + sha256: str + duplicate: bool + tier: Tier + verified: bool + doc_type: str + institution_id: str | None + facts_written: int + analysis: DkimAnalysis | None = None + extraction_status: str = "skipped" + extraction_error: str | None = None + + def as_dict(self) -> dict[str, object]: + return { + "document_id": self.document_id, + "sha256": self.sha256, + "duplicate": self.duplicate, + "tier": self.tier.slug, + "verified": self.verified, + "doc_type": self.doc_type, + "institution_id": self.institution_id, + "facts_written": self.facts_written, + "extraction_status": self.extraction_status, + "extraction_error": self.extraction_error, + "dkim": { + "verified": self.analysis.verdict.verified if self.analysis else False, + "d_domain": self.analysis.verdict.d_domain if self.analysis else None, + "selector": self.analysis.verdict.selector if self.analysis else None, + "l_tag_present": self.analysis.verdict.l_tag_present + if self.analysis + else False, + "dns_record_hash": self.analysis.verdict.dns_record_hash + if self.analysis + else None, + "failure_reason": self.analysis.verdict.failure_reason + if self.analysis + else None, + "signatures": [s.as_dict() for s in self.analysis.summaries] + if self.analysis + else [], + }, + } + + +# -- steps 1 to 4 --------------------------------------------------------- + + +def ingest( + session: Session, + *, + entity_id: str, + record_id: str, + raw: bytes, + channel: Channel = Channel.EML_UPLOAD, + filename: str | None = None, + resolver: DnsResolver | None = None, + now: datetime | None = None, +) -> IngestOutcome: + now = now or datetime.now(timezone.utc) + resolver = resolver or default_resolver() + + # 1. Receive. Hash first; the hash is the identity of the evidence. + sha256 = hashlib.sha256(raw).hexdigest() + existing = session.scalar( + select(db.Document).where( + db.Document.sha256 == sha256, db.Document.entity_id == entity_id + ) + ) + if existing is not None: + return IngestOutcome( + document_id=existing.id, + sha256=sha256, + duplicate=True, + tier=Tier.from_slug(_document_tier(session, existing.id)), + verified=_document_verified(session, existing.id), + doc_type=existing.doc_type, + institution_id=existing.institution_id, + facts_written=0, + ) + + blob_key = blob_store().put(raw) + + # 2. Verify. DNS names come from the signature, an adapter resolves them, + # and the pure verifier judges. The captured record is stored, because + # once the selector is retired it is the only thing keeping the + # signature checkable. + names = required_dns_names(raw) + records = resolver.txt(names) if names else {} + analysis = verify_message( + raw, + dns_records=records, + institutions=active_institutions(), + captured_at=now, + ) + verdict = analysis.verdict + institution = analysis.institution + + # Tier is assigned here and nowhere else. + tier = tier_for(channel, verdict.verified) + + # 3. Extract. Independent of the verdict: a tampered document still parses, + # it just parses as self-reported. Refusing to read it would hide the + # tamper rather than grade it. + extractor = registry.sniff(institution.id if institution else None, raw) + if extractor is None: + extractor = registry.sniff(None, raw) + + doc_type = extractor.doc_type if extractor else DocType.UNKNOWN + + document = db.Document( + entity_id=entity_id, + record_id=record_id, + sha256=sha256, + blob_key=blob_key, + byte_length=len(raw), + received_at=now, + channel=channel.value, + institution_id=institution.id if institution else None, + doc_type=doc_type.value, + filename=filename, + ) + session.add(document) + session.flush() + + session.add( + db.DkimVerdictRow( + document_id=document.id, + verified=verdict.verified, + d_domain=verdict.d_domain, + selector=verdict.selector, + algo=verdict.algo, + l_tag_present=verdict.l_tag_present, + body_hash_matched=verdict.body_hash_matched, + dns_txt_record=verdict.dns_txt_record, + dns_captured_at=verdict.dns_captured_at, + signed_headers=list(verdict.signed_headers), + signature_summaries=[s.as_dict() for s in analysis.summaries], + failure_reason=verdict.failure_reason, + ) + ) + + if extractor is None: + session.flush() + return IngestOutcome( + document_id=document.id, + sha256=sha256, + duplicate=False, + tier=tier, + verified=verdict.verified, + doc_type=doc_type.value, + institution_id=institution.id if institution else None, + facts_written=0, + analysis=analysis, + extraction_status="unsupported", + extraction_error="no extractor recognizes this document", + ) + + result = extractor.extract(raw) + session.add( + db.ExtractionRow( + document_id=document.id, + extractor_id=extractor.id, + extractor_version=extractor.version, + status=result.status, + payload=_jsonable(result.payload), + error=result.error, + ) + ) + + # 4. Normalize. Facts inherit the document's tier; nothing here may raise it. + written = 0 + if result.ok and result.facts: + account = _account_for( + session, + entity_id=entity_id, + institution_id=extractor.institution_id, + external_ref=result.payload.get("account_ref"), + currency=result.payload.get("base_currency", "USD"), + ) + for extracted in result.facts: + fact = extracted.bind( + account_id=account.id, + document_sha256=sha256, + tier=tier, + extractor_version=extractor.version, + ) + session.add( + db.FactRow( + record_id=record_id, + account_id=account.id, + document_id=document.id, + as_of=fact.as_of, + kind=fact.kind.value, + instrument=fact.instrument, + quantity=fact.quantity, + price=fact.price, + amount_minor=fact.amount_minor, + currency=fact.currency, + tier=fact.tier.slug, + extractor_version=fact.extractor_version, + note=fact.note, + ) + ) + written += 1 + + session.flush() + return IngestOutcome( + document_id=document.id, + sha256=sha256, + duplicate=False, + tier=tier, + verified=verdict.verified, + doc_type=doc_type.value, + institution_id=institution.id if institution else None, + facts_written=written, + analysis=analysis, + extraction_status=result.status, + extraction_error=result.error, + ) + + +# -- steps 5 to 7 --------------------------------------------------------- + + +@dataclass +class SnapshotOutcome: + snapshot: Snapshot + snapshot_id: str + metrics: tuple[MetricResult, ...] + findings: tuple[Finding, ...] + anchor_status: str + tx_hash: str | None = None + block_number: int | None = None + chain_seq: int | None = None + anchor_error: str | None = None + + +def rebuild( + session: Session, + *, + record_id: str, + config: Config | None = None, + anchor: bool = True, +) -> SnapshotOutcome: + """Recompute a record from its evidence and commit the result. + + Everything downstream of the facts is derived, every time. There is no + incremental update path on purpose: a cached metric that disagrees with the + evidence is worse than a slow one. + """ + record = session.get(db.Record, record_id) + if record is None: + raise LookupError(f"no record {record_id}") + + fact_rows = list( + session.scalars( + select(db.FactRow) + .where(db.FactRow.record_id == record_id) + .order_by(db.FactRow.as_of, db.FactRow.id) + ) + ) + documents, verdicts = _evidence(session, record_id) + facts = tuple(_to_fact(row) for row in fact_rows) + + # 5. Reconcile. Conflicts are written down, not raised. + findings = list(reconcile(facts)) + + # 6. Compute. + metrics: tuple[MetricResult, ...] = () + if facts: + try: + series = build_series(facts) + metrics = compute(series, config or Config()) + except (InsufficientSeries, Uncomputable) as exc: + findings.append( + Finding( + kind="metrics_uncomputable", + severity=Severity.WARNING, + detail=str(exc), + ) + ) + + extractor_versions = registry.versions() + + seq = ( + session.scalar( + select(db.Snapshot.seq) + .where(db.Snapshot.record_id == record_id) + .order_by(db.Snapshot.seq.desc()) + .limit(1) + ) + or 0 + ) + 1 + + snapshot = build_snapshot( + record_id=record.slug, + seq=seq, + documents=documents, + verdicts=verdicts, + facts=facts, + metrics=metrics, + methodology_version=METHODOLOGY_VERSION, + extractor_versions=extractor_versions, + findings=tuple(findings), + ) + + row = db.Snapshot( + record_id=record_id, + seq=seq, + root=snapshot.root_hex, + metrics_hash=snapshot.metrics_hash, + methodology_version=METHODOLOGY_VERSION, + extractor_versions=extractor_versions, + tier=snapshot.tier.slug, + leaves=[leaf.as_dict() for leaf in snapshot.leaves], + ) + session.add(row) + session.flush() + + for metric in metrics: + session.add( + db.MetricRow( + snapshot_id=row.id, + key=metric.key, + basis=metric.basis, + value=metric.value, + unit=metric.unit, + formula=metric.formula, + inputs=_jsonable(metric.inputs), + tier=metric.tier.slug, + ) + ) + for finding in findings: + session.add( + db.FindingRow( + snapshot_id=row.id, + kind=finding.kind, + severity=finding.severity.value, + detail=finding.detail, + as_of=finding.as_of, + ) + ) + + # 7. Commit. + receipt = None + if anchor: + receipt = chain_client().anchor(record.chain_key, snapshot.root_hex) + session.add( + db.Anchor( + snapshot_id=row.id, + chain_id=settings().chain_id, + root=snapshot.root_hex, + seq=seq, + status=receipt.status, + tx_hash=receipt.tx_hash, + block_number=receipt.block_number, + confirmed_at=receipt.confirmed_at, + error=receipt.error, + ) + ) + + session.flush() + return SnapshotOutcome( + snapshot=snapshot, + snapshot_id=row.id, + metrics=metrics, + findings=tuple(findings), + anchor_status=receipt.status if receipt else "not_attempted", + tx_hash=receipt.tx_hash if receipt else None, + block_number=receipt.block_number if receipt else None, + chain_seq=receipt.seq if receipt else None, + anchor_error=receipt.error if receipt else None, + ) + + +# -- helpers -------------------------------------------------------------- + + +def _evidence( + session: Session, record_id: str +) -> tuple[list[Document], list[tuple[str, DkimVerdict]]]: + """Every document held for this record, and what we made of its signature. + + Documents that produced no facts are still committed to. A tampered message + we rejected is evidence of the rejection, and dropping it from the tree + would let us quietly forget having seen it. + """ + rows = list( + session.scalars( + select(db.Document) + .where(db.Document.record_id == record_id) + .order_by(db.Document.sha256) + ) + ) + documents = [ + Document( + sha256=row.sha256, + blob_key=row.blob_key, + byte_length=row.byte_length, + received_at=db.as_utc(row.received_at), + channel=Channel(row.channel), + institution_id=row.institution_id, + doc_type=DocType(row.doc_type), + ) + for row in rows + ] + + verdicts: list[tuple[str, DkimVerdict]] = [] + by_id = {row.id: row.sha256 for row in rows} + if by_id: + for verdict_row in session.scalars( + select(db.DkimVerdictRow).where( + db.DkimVerdictRow.document_id.in_(list(by_id)) + ) + ): + verdicts.append( + ( + by_id[verdict_row.document_id], + DkimVerdict( + verified=verdict_row.verified, + d_domain=verdict_row.d_domain, + selector=verdict_row.selector, + algo=verdict_row.algo, + l_tag_present=verdict_row.l_tag_present, + dns_txt_record=verdict_row.dns_txt_record, + dns_captured_at=db.as_utc(verdict_row.dns_captured_at), + body_hash_matched=verdict_row.body_hash_matched, + signed_headers=tuple(verdict_row.signed_headers or ()), + failure_reason=verdict_row.failure_reason, + ), + ) + ) + verdicts.sort(key=lambda pair: pair[0]) + return documents, verdicts + + +def _to_fact(row: db.FactRow) -> Fact: + return Fact( + account_id=row.account_id, + document_sha256=_document_sha(row), + as_of=row.as_of, + kind=FactKind(row.kind), + tier=Tier.from_slug(row.tier), + currency=row.currency, + instrument=row.instrument, + quantity=row.quantity, + price=row.price, + amount_minor=row.amount_minor, + extractor_version=row.extractor_version, + note=row.note, + ) + + +def _document_sha(row: db.FactRow) -> str: + session = Session.object_session(row) + if session is None: # pragma: no cover - detached rows are a caller error + raise RuntimeError("fact row is not attached to a session") + document = session.get(db.Document, row.document_id) + return document.sha256 if document else row.document_id + + +def _account_for( + session: Session, + *, + entity_id: str, + institution_id: str, + external_ref: str | None, + currency: str, +) -> db.Account: + query = select(db.Account).where( + db.Account.entity_id == entity_id, + db.Account.institution_id == institution_id, + db.Account.external_ref == external_ref, + ) + account = session.scalar(query) + if account is None: + account = db.Account( + entity_id=entity_id, + institution_id=institution_id, + external_ref=external_ref, + currency=currency, + ) + session.add(account) + session.flush() + return account + + +def _document_tier(session: Session, document_id: str) -> str: + tier = session.scalar( + select(db.FactRow.tier).where(db.FactRow.document_id == document_id).limit(1) + ) + return tier or Tier.SELF_REPORTED.slug + + +def _document_verified(session: Session, document_id: str) -> bool: + return bool( + session.scalar( + select(db.DkimVerdictRow.verified).where( + db.DkimVerdictRow.document_id == document_id + ) + ) + ) + + +def _jsonable(value): + """JSON columns cannot hold Decimals or dates; canonical strings can.""" + from datetime import date as _date + from decimal import Decimal + + if isinstance(value, dict): + return {k: _jsonable(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_jsonable(v) for v in value] + if isinstance(value, Decimal): + from core.canonical import canonical_decimal + + return canonical_decimal(value) + if isinstance(value, (_date, datetime)): + return value.isoformat() + return value