diff --git a/Cargo.lock b/Cargo.lock index 0c9f338..429a801 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -748,7 +748,7 @@ dependencies = [ [[package]] name = "zkac" -version = "0.5.1" +version = "0.7.1" dependencies = [ "blake2", "chacha20poly1305", diff --git a/Cargo.toml b/Cargo.toml index 576a8dd..b6f2861 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zkac" -version = "0.5.1" +version = "0.7.1" edition = "2021" description = "Zero-Knowledge Access Control: BBS+ anonymous credentials (BLS12-381) with encrypted transport (X25519/ChaCha20-Poly1305)" diff --git a/README.md b/README.md index 6d9c95a..eba6704 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ python -c "import zkac; print(zkac.role_id('admin').hex())" Run tests: `cargo test` and `pytest tests/test_zkac.py`. -Local web UI over the CLI: `demo/cli_web_server.py` (see `demo/README.md`). Optional in-browser WASM for the legacy Flask demo: `wasm/README.md` and `./demo/build_wasm.sh` (needs **rustup** + `wasm32-unknown-unknown`). +Local web UI over the CLI: `demo/cli_web_server.py` (see `demo/README.md`). Optional in-browser WASM for the Flask demo: `wasm/README.md` and `./demo/build_wasm.sh` (needs **rustup** + `wasm32-unknown-unknown`). ## License diff --git a/cli/README.md b/cli/README.md index 0d166d8..240fddf 100644 --- a/cli/README.md +++ b/cli/README.md @@ -5,6 +5,7 @@ Install the `zkac` wheel from the repo root first (`maturin develop` or `pip ins ```bash pip install -e ./cli zkac-node --help +zkac-node-i2p-server --help ``` ## Quick start @@ -14,28 +15,24 @@ zkac-node --help zkac-node user create alice zkac-node user create bob -# Bob shares his issuance public key with Alice out-of-band: -# zkac-node user show bob → copy issuance pk +# Bob shares one contact string with Alice out-of-band: +# zkac-node user show bob --peer 127.0.0.1:9810 # 2. Alice runs a server; pin its public key for clients zkac-node serve alice --port 9800 & zkac-node server pin alice localhost:9800 --key zkac-node server pin bob localhost:9800 --key -# 3. Alice creates a registry and grants Bob a role (needs Bob's issuance pk hex) +# 3. Alice creates a registry and grants Bob a role directly (needs Bob's contact string) zkac-node registry create alice localhost:9800 --roles analyst,operator zkac-node grant alice --server localhost:9800 \ - --registry --role analyst --to $BOB_PK_HEX -# (prints pool_index for Bob's collect) + --registry --role analyst --to "$BOB_CONTACT" -# 4. Bob lists local creds + pending grants (single-server PIR, no second replica needed) +# 4. Bob listens for direct p2p delivery +zkac-node p2p-listen bob --host 127.0.0.1 --port 9810 + +# 5. Bob lists local creds zkac-node credentials list bob -zkac-node credentials list bob --server localhost:9800 - -# 5. Bob collects (auto-discovers via detection tags; --pool-index is optional) -zkac-node collect bob localhost:9800::analyst -# or with explicit index: -zkac-node collect bob localhost:9800::analyst --pool-index # 6. Bob authenticates zkac-node auth bob --registry --role analyst --server localhost:9800 @@ -48,24 +45,81 @@ zkac-node auth bob --registry --role analyst --server localhost:98 | `user create ` | Generate issuance keypair under `~/.zkac//` | | `user list` | List all local user ids | | `user show ` | Show issuance pk + owned registries + credentials | -| `serve [--data-dir D]` | Run server; default data dir is `~/.zkac//server/` | +| `serve [--data-dir D]` | Run server; default data dir is `~/.zkac//server/` (loopback-only unless `--allow-non-loopback`) | +| `zkac-node-i2p-server [--host H --port P]` | Same as `serve`, for I2P server-tunnel exposure (see below) | | `server pin --key ` | Pin server public key for that user | | `registry create --roles …` | Create registry on server | | `registry update --registry R --add-roles …` | Add roles | | `registry get --registry R` | Fetch registry state | | `registry list ` | List registries this user owns locally | -| `grant --server S --registry R --role X --to ` | Admin grant (encrypted to recipient pk) | -| `credentials list [--server S …]` | Pending grants: tags + SimplePIR (full encrypted row) + decrypt | -| `collect [--pool-index N]` | Same retrieval path, then local credential finalize | +| `grant --server S --registry R --role X --to ` | Admin direct p2p grant (recipient contact bundle must include peer endpoint) | +| `p2p-listen [--host H --port P]` | Receive one direct p2p grant and store credential (loopback-only unless `--allow-non-loopback`) | +| `credentials list ` | List local credentials | | `auth --registry R --role X [--server S]` | Authenticated session | ## Protocol & threat model -See [docs/SECURITY.md](../docs/SECURITY.md) in the repo root for the full model, including PIR and detection tags. +See [docs/SECURITY.md](../docs/SECURITY.md) in the repo root for the direct p2p grant model. -**Custom clients:** Encrypted management JSON supports `pool_tags`, `pir_hints`, and `pir_query`. Mailbox retrieval is single-hop: decode a full encrypted row from PIR and decrypt locally. +## Admin debug web UI (demo) -**Operational scaling:** The server grant pool is append-only, so pool length grows with every grant. Large pools increase discovery traffic, PIR query size, and server work per retrieval (all linear in pool length). Treat unbounded growth as a potential DoS and capacity risk; mitigations are listed under *Known limitations* and *Future work* in `docs/SECURITY.md`. Transport, BBS+ auth, registry state updates, and issuance queues have separate scaling profiles (CPU dominated by BBS+, state size linear in role count, queue memory); see **Scaling and complexity (transport, credentials, registries)** in the same doc. +For a **fully transparent** HTTP dashboard (registry rows, live TCP sessions, data-dir +listing), run **`demo/zkac_admin_serve.py`** from the repo with **`uv sync --extra demo`**. +See [demo/README.md](../demo/README.md). + +## Running over I2P + +### Inbound: `zkac-node-i2p-server` + +Run the node on a loopback TCP port and forward it from an **I2P server tunnel** +(I2P or i2pd). Clients use your published **`*.b32.i2p:port`** as the server +string in all `zkac-node` commands. + +```bash +zkac-node-i2p-server alice --host 127.0.0.1 --port 9800 +``` + +They should **pin** that same `host:port` string (the `.b32.i2p` form), not `localhost`. + +### Outbound clients + +Outbound client connections can be proxied through an I2P SOCKS tunnel. + +1. Start I2P/i2pd SOCKS proxy (commonly `127.0.0.1:4447`). +2. Export: + +```bash +export ZKAC_SOCKS5_PROXY=127.0.0.1:4447 +``` + +If the destination ends in `.i2p` and `ZKAC_SOCKS5_PROXY` is not set, `zkac-node` fails fast instead of attempting a direct clearnet connect. + +3. Use `.b32.i2p:port` endpoints with normal commands: + +```bash +zkac-node registry get alice exampledestination.b32.i2p:9800 --registry +zkac-node auth bob --registry --role analyst --server exampledestination.b32.i2p:9800 +``` + +For inbound direct grants, run `p2p-listen` on a local port and expose it via an I2P inbound tunnel. Peers dial your published I2P destination/port; your tunnel forwards to local `host:port`. + +`p2p-listen` now refuses non-loopback binds by default (use `--allow-non-loopback` only when intentional). + +Connectivity sanity check: + +```bash +zkac-node net check exampledestination.b32.i2p:9800 +zkac-node net check exampledestination.b32.i2p:9800 --handshake --key +# or use an existing pin: +zkac-node net check exampledestination.b32.i2p:9800 --handshake --userid alice +``` + +With proxy: + +```bash +export ZKAC_SOCKS5_PROXY=127.0.0.1:4447 +zkac-node net check exampledestination.b32.i2p:9800 +``` ## Storage layout @@ -73,10 +127,9 @@ Per user `~/.zkac//`: ``` identity.json issuance keypair + p2p transport keypair admin/.json BBS+ admin material for owned registries credentials/_.json received credentials -servers/.json pinned server public keys -pir_cache/.json PIR hint metadata (pool_version, n_records) -pir_cache/.bin PIR hint data (cached, keyed by pool_version) -server/ (only if you run `serve `) server_key.json, registries/, mailbox/grants_pool.json +servers/sha256_<...>.json pinned server public keys (includes original server string) +server/ (only if you run `serve `) server_key.json, registries/ ``` diff --git a/cli/pyproject.toml b/cli/pyproject.toml index 51036b8..a81ce0f 100644 --- a/cli/pyproject.toml +++ b/cli/pyproject.toml @@ -1,11 +1,12 @@ [project] name = "zkac-node" -version = "0.2.1" +version = "0.7.1" requires-python = ">=3.10" dependencies = ["zkac"] [project.scripts] zkac-node = "zkac_cli.main:main" +zkac-node-i2p-server = "zkac_cli.i2p_serve:main" [build-system] requires = ["setuptools>=68"] diff --git a/cli/zkac_cli/client.py b/cli/zkac_cli/client.py index e742e7d..90bbda3 100644 --- a/cli/zkac_cli/client.py +++ b/cli/zkac_cli/client.py @@ -1,18 +1,21 @@ -"""Client-side operations over a unified encrypted channel (per local user id).""" +"""Client-side operations for registry management and direct P2P grants.""" from __future__ import annotations import base64 -import hashlib import json +import os import socket -from pathlib import Path +import struct +import time import zkac -from zkac.tcp import FramedSession, client_handshake_anon +from zkac.tcp import FramedSession, client_handshake_anon, server_handshake_anon from . import store +DEFAULT_CONNECT_TIMEOUT_S = 8.0 + def _b64(data: bytes) -> str: return base64.b64encode(data).decode() @@ -41,6 +44,178 @@ def _parse_server(server: str) -> tuple[str, int]: return (host or "127.0.0.1"), port +def _parse_host_port(value: str, name: str) -> tuple[str, int]: + host, sep, port_s = value.rpartition(":") + if not sep: + raise ValueError(f"{name} must be host:port") + try: + port = int(port_s, 10) + except ValueError as e: + raise ValueError(f"{name} port must be numeric") from e + if not 1 <= port <= 65535: + raise ValueError(f"{name} port out of range: {port}") + return (host or "127.0.0.1"), port + + +def _proxy_target() -> tuple[str, int] | None: + raw = os.environ.get("ZKAC_SOCKS5_PROXY", "").strip() + if not raw: + return None + return _parse_host_port(raw, "ZKAC_SOCKS5_PROXY") + + +def _is_i2p_host(host: str) -> bool: + return host.strip().lower().endswith(".i2p") + + +def _maybe_i2p_pin_hint(userid: str, server: str) -> str: + host, _port = _parse_server(server) + is_i2p_target = _is_i2p_host(host) + known = store.known_servers(userid) + candidates: list[str] = [] + for known_server in known: + try: + known_host, _ = _parse_server(known_server) + except ValueError: + continue + if _is_i2p_host(known_host) == is_i2p_target: + continue + candidates.append(known_server) + if not candidates: + return "" + choices = ", ".join(sorted(candidates)[:3]) + return ( + " You appear to have pins under a different address style " + f"({choices}). Pins are exact host:port keys; pin and connect with the same endpoint string." + ) + + +def _recv_exact(sock: socket.socket, n: int) -> bytes: + buf = bytearray() + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + raise ConnectionError("connection closed during SOCKS5 handshake") + buf.extend(chunk) + return bytes(buf) + + +def _socks5_connect(sock: socket.socket, host: str, port: int): + sock.sendall(b"\x05\x01\x00") + hello = _recv_exact(sock, 2) + if hello != b"\x05\x00": + raise RuntimeError("SOCKS5 proxy requires unsupported authentication") + host_b = host.encode("idna") + if len(host_b) > 255: + raise ValueError("destination host too long for SOCKS5") + req = b"\x05\x01\x00\x03" + bytes([len(host_b)]) + host_b + struct.pack(">H", port) + sock.sendall(req) + head = _recv_exact(sock, 4) + if head[0] != 0x05: + raise RuntimeError("invalid SOCKS5 proxy reply") + if head[1] != 0x00: + raise RuntimeError(f"SOCKS5 connect failed (code=0x{head[1]:02x})") + atyp = head[3] + if atyp == 0x01: + _ = _recv_exact(sock, 4 + 2) + elif atyp == 0x03: + ln = _recv_exact(sock, 1)[0] + _ = _recv_exact(sock, ln + 2) + elif atyp == 0x04: + _ = _recv_exact(sock, 16 + 2) + else: + raise RuntimeError("invalid SOCKS5 address type in reply") + + +def _connect_with_timeout(address: tuple[str, int], timeout_s: float = DEFAULT_CONNECT_TIMEOUT_S) -> socket.socket: + sock = socket.create_connection(address, timeout=timeout_s) + sock.settimeout(None) + return sock + + +def _connect(host: str, port: int, *, connect_timeout_s: float = DEFAULT_CONNECT_TIMEOUT_S) -> socket.socket: + proxy = _proxy_target() + if _is_i2p_host(host) and proxy is None: + raise RuntimeError( + "destination is .i2p but ZKAC_SOCKS5_PROXY is not set. " + "Set ZKAC_SOCKS5_PROXY=127.0.0.1:4447 (or your I2P SOCKS endpoint)." + ) + # Only route through SOCKS5 for I2P destinations. This keeps local/direct + # peer delivery (e.g. 127.0.0.1) working even when ZKAC_SOCKS5_PROXY is set. + if proxy is None or not _is_i2p_host(host): + return _connect_with_timeout((host, port), connect_timeout_s) + sock = _connect_with_timeout(proxy, connect_timeout_s) + try: + _socks5_connect(sock, host, port) + return sock + except Exception: + sock.close() + raise + + +def net_check( + target: str, + timeout_s: float = 8.0, + *, + handshake: bool = False, + userid: str | None = None, + server_key_hex: str | None = None, +) -> dict: + """Connectivity diagnostic for direct/server endpoints, with optional SOCKS5 and handshake.""" + host, port = _parse_server(target) + proxy = _proxy_target() + if _is_i2p_host(host) and proxy is None: + return { + "ok": False, + "target": target, + "via": "direct", + "error": ( + "destination is .i2p but ZKAC_SOCKS5_PROXY is not set; " + "configure your I2P SOCKS proxy (for example 127.0.0.1:4447)" + ), + } + use_proxy = proxy is not None and _is_i2p_host(host) + via = "direct" if not use_proxy else f"socks5:{proxy[0]}:{proxy[1]}" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout_s) + try: + if not use_proxy: + sock.connect((host, port)) + else: + assert proxy is not None + sock.connect(proxy) + _socks5_connect(sock, host, port) + + result = {"ok": True, "target": target, "via": via} + if handshake: + if server_key_hex and userid: + return { + "ok": False, + "target": target, + "via": via, + "error": "use either server_key_hex or userid for handshake, not both", + } + if server_key_hex: + server_pk = zkac.PublicKey.from_bytes(bytes.fromhex(server_key_hex)) + elif userid: + server_pk = _resolve_server_pk(userid, target) + else: + return { + "ok": False, + "target": target, + "via": via, + "error": "handshake check requires --key or --userid ", + } + node = zkac.Node(zkac.Keypair()) + _ = client_handshake_anon(sock, node, server_pk) + result["handshake"] = "ok" + return result + except Exception as exc: + return {"ok": False, "target": target, "via": via, "error": str(exc)} + finally: + sock.close() + + def parse_spec(spec: str) -> tuple[str, str, str]: """Parse 'host:port:registry_id:role' into (server, registry_id, role).""" parts = spec.rsplit(":", 2) @@ -52,24 +227,32 @@ def parse_spec(spec: str) -> tuple[str, str, str]: def _resolve_server_pk(userid: str, server: str) -> zkac.PublicKey: pin = store.load_server_pin(userid, server) if pin is None: + hint = _maybe_i2p_pin_hint(userid, server) raise RuntimeError( f"no pinned transport key for {server!r} under client {userid!r} " f"(pins are per client identity in ~/.zkac/{userid}/, not the userid " "passed to `zkac-node serve <…>` on the server). " - f"Run: zkac-node server pin {userid} {server} --key " + f"Run: zkac-node server pin {userid} {server} --key ." + f"{hint}" ) return zkac.PublicKey.from_bytes(_unb64(pin["server_public_key_b64"])) -def _mgmt_connect(userid: str, server: str) -> tuple[socket.socket, FramedSession]: +def _local_node(userid: str) -> zkac.Node: + ident = store.load_identity(userid) + keypair = zkac.Keypair.from_secret_key(ident["transport_sk"]) + return zkac.Node(keypair) + + +def _mgmt_connect(userid: str, server: str) -> tuple[socket.socket, FramedSession, bytes]: host, port = _parse_server(server) - sock = socket.create_connection((host, port)) + sock = _connect(host, port, connect_timeout_s=DEFAULT_CONNECT_TIMEOUT_S) server_pk = _resolve_server_pk(userid, server) - node = zkac.Node(zkac.Keypair()) + node = _local_node(userid) session = client_handshake_anon(sock, node, server_pk) framed = FramedSession(sock, session) framed.send(json.dumps({"op": "mgmt"}).encode()) - return sock, framed + return sock, framed, bytes(session.transcript_hash()) def _mgmt_cmd(framed: FramedSession, cmd: dict) -> dict: @@ -77,9 +260,22 @@ def _mgmt_cmd(framed: FramedSession, cmd: dict) -> dict: return json.loads(framed.recv()) -def _mgmt_single(userid: str, server: str, cmd: dict) -> dict: - sock, framed = _mgmt_connect(userid, server) +def _mgmt_single( + userid: str, + server: str, + cmd: dict, + *, + auth_registry_id: str | None = None, + admin_cred: zkac.Credential | None = None, +) -> dict: + sock, framed, transcript_hash = _mgmt_connect(userid, server) try: + if auth_registry_id is not None: + if admin_cred is None: + raise RuntimeError("admin credential required for authenticated management command") + cmd = dict(cmd) + cmd["auth_registry_id"] = auth_registry_id + cmd["admin_proof_b64"] = _b64(admin_cred.present(transcript_hash)) return _ok(_mgmt_cmd(framed, cmd)) finally: sock.close() @@ -91,139 +287,49 @@ def _ok(resp: dict) -> dict: return resp -# ── PIR hint cache ─────────────────────────────────────────────────── - -def _cache_dir(userid: str) -> Path: - d = store.user_dir(userid) / "pir_cache" - d.mkdir(parents=True, exist_ok=True) - return d - - -def _server_cache_key(server: str) -> str: - return server.replace(":", "_") - - -def _load_cached_hints(userid: str, server: str, pool_version: str) -> bytes | None: - meta_path = _cache_dir(userid) / f"{_server_cache_key(server)}.json" - bin_path = _cache_dir(userid) / f"{_server_cache_key(server)}.bin" - if not meta_path.exists() or not bin_path.exists(): - return None - meta = json.loads(meta_path.read_text()) - if meta.get("pool_version") != pool_version: - return None - return bin_path.read_bytes() - - -def _save_cached_hints(userid: str, server: str, pool_version: str, - n_records: int, record_bytes: int, hints_bytes: bytes): - key = _server_cache_key(server) - meta = {"pool_version": pool_version, "n_records": n_records, "record_bytes": record_bytes} - (_cache_dir(userid) / f"{key}.json").write_text(json.dumps(meta)) - (_cache_dir(userid) / f"{key}.bin").write_bytes(hints_bytes) - - -def _fetch_hints_bytes(framed: FramedSession) -> tuple[bytes, str]: - """Download PIR hint blob; uses chunked wire protocol when hints exceed one frame.""" - resp = _ok(_mgmt_cmd(framed, {"cmd": "pir_hints"})) - if "hints_b64" in resp: - return _unb64(resp["hints_b64"]), resp["pool_version"] - - total = int(resp["hints_total"]) - pv = resp["pool_version"] - buf = bytearray() - off = 0 - while off < total: - resp = _ok( - _mgmt_cmd( - framed, - {"cmd": "pir_hints", "offset": off, "pool_version": pv}, - ) - ) - if resp.get("pool_version") != pv: - raise RuntimeError("PIR hints pool_version changed during download") - piece = _unb64(resp["slice_b64"]) - if not piece and off < total: - raise RuntimeError("PIR hints short read") - buf.extend(piece) - off += len(piece) - if len(buf) != total: - raise RuntimeError( - f"PIR hints size mismatch (got {len(buf)}, expected {total})" - ) - return bytes(buf), pv - - -def _pir_client(userid: str, framed: FramedSession, server: str) -> tuple[zkac.PirClient, str]: - """Fetch pool_info, load or refresh hints, return (PirClient, pool_version).""" - info = _ok(_mgmt_cmd(framed, {"cmd": "pool_info"})) - n = info["n"] - rb = info["record_bytes"] - pv = info["pool_version"] - - cached = _load_cached_hints(userid, server, pv) - if cached is not None: - return zkac.PirClient(cached, n, rb), pv - - hints_bytes, pv = _fetch_hints_bytes(framed) - _save_cached_hints(userid, server, pv, n, rb, hints_bytes) - return zkac.PirClient(hints_bytes, n, rb), pv - - -def _fetch_row( - userid: str, framed: FramedSession, server: str, - pir_client: zkac.PirClient, pool_version: str, pool_index: int, -) -> dict: - q, state = pir_client.query(pool_index) - q_b64 = _b64(q) - resp = _ok( - _mgmt_cmd( - framed, - {"cmd": "pir_query", "query_b64": q_b64, "pool_version": pool_version}, - ) +def _validated_admin_update_base( + userid: str, + server: str, + registry_id_hex: str, +) -> tuple[dict, zkac.BbsPublicKey, zkac.Credential, bytes, int]: + admin_data = store.load_admin(userid, registry_id_hex) + _bbs_issuer, bbs_pk, admin_cred = store.reconstruct_admin(admin_data) + cur = _mgmt_single( + userid, + server, + {"cmd": "get_registry", "registry_id": registry_id_hex}, + auth_registry_id=registry_id_hex, + admin_cred=admin_cred, ) - if "answer_b64" in resp: - ans_bytes = _unb64(resp["answer_b64"]) + + old_state = zkac.RegistryState.deserialize(_unb64(cur["state_bytes_b64"])) + prev_hash = bytes(old_state.state_hash()) + server_version = old_state.version() + local_version = admin_data.get("last_known_version") + local_hash_b64 = admin_data.get("last_known_state_hash_b64") + if local_version != server_version: + raise RuntimeError( + "local admin metadata is stale versus server state version; " + "refetch/synchronize admin metadata before updating" + ) + if not isinstance(local_hash_b64, str) or _unb64(local_hash_b64) != prev_hash: + raise RuntimeError( + "local admin metadata state hash mismatch; refusing update to avoid accidental role/state clobber" + ) + + return admin_data, bbs_pk, admin_cred, prev_hash, server_version + 1 + + +def _normalize_role_epochs(all_roles: list[str], raw_role_epochs: object) -> dict[str, int]: + role_epochs: dict[str, int] = {} + if isinstance(raw_role_epochs, dict): + for role in all_roles: + value = raw_role_epochs.get(role, 1) + role_epochs[role] = max(int(value), 1) else: - total = int(resp["answer_total"]) - buf = bytearray() - off = 0 - while off < total: - chunk = _ok( - _mgmt_cmd( - framed, - { - "cmd": "pir_query", - "query_b64": q_b64, - "pool_version": pool_version, - "offset": off, - }, - ) - ) - piece = _unb64(chunk["slice_b64"]) - if not piece and off < total: - raise RuntimeError("PIR answer short read") - buf.extend(piece) - off += len(piece) - if len(buf) != total: - raise RuntimeError( - f"PIR answer size mismatch (got {len(buf)}, expected {total})" - ) - ans_bytes = bytes(buf) - raw = bytes(pir_client.decode(ans_bytes, state)) - row = json.loads(raw.rstrip(b"\x00").decode("utf-8")) - if row.get("v") != 2: - raise RuntimeError("unsupported PIR row version") - ct_b64 = row.get("ciphertext_b64", "") - expect_digest = row.get("ciphertext_sha256", "") - actual = hashlib.sha256(_unb64(ct_b64)).hexdigest() if ct_b64 else "" - if actual != expect_digest: - raise RuntimeError("PIR row ciphertext digest mismatch") - return { - "eph_pk_b64": row.get("eph_pk_b64", ""), - "ciphertext_b64": ct_b64, - "to_tag_b64": row.get("to_tag_b64", ""), - "claimed": row.get("claimed", False), - } + for role in all_roles: + role_epochs[role] = 1 + return role_epochs # ── Public operations ──────────────────────────────────────────────── @@ -245,36 +351,33 @@ def create_registry(userid: str, server: str, role_names: list[str]) -> str: "cmd": "create_registry", "state_bytes_b64": _b64(state_bytes), "state_cert_b64": _b64(bytes(state_cert)), - }) + }, auth_registry_id=registry_id.hex(), admin_cred=admin_cred) rid_hex = resp["registry_id"] store.save_admin(userid, rid_hex, { "server": server, "roles": role_names, + "role_epochs": {name: 1 for name in role_names}, + "last_known_version": 1, + "last_known_state_hash_b64": _b64(state.state_hash()), **admin_mat, }) return rid_hex def update_registry(userid: str, server: str, registry_id_hex: str, add_roles: list[str]): - admin_data = store.load_admin(userid, registry_id_hex) - bbs_issuer, bbs_pk, admin_cred = store.reconstruct_admin(admin_data) + admin_data, bbs_pk, admin_cred, prev_hash, new_version = _validated_admin_update_base( + userid, server, registry_id_hex, + ) identity = store.load_identity(userid) - cur = _mgmt_single(userid, server, { - "cmd": "get_registry", "registry_id": registry_id_hex, - }) - - old_state = zkac.RegistryState.deserialize(_unb64(cur["state_bytes_b64"])) - prev_hash = old_state.state_hash() - new_version = old_state.version() + 1 - old_roles = admin_data.get("roles", []) all_roles = list(old_roles) + [r for r in add_roles if r not in old_roles] - role_entries = [(zkac.role_id(name), bbs_pk, 1) for name in all_roles] + role_epochs = _normalize_role_epochs(all_roles, admin_data.get("role_epochs", {})) + role_entries = [(zkac.role_id(name), bbs_pk, role_epochs[name]) for name in all_roles] new_state = zkac.RegistryState.build( - bbs_pk, identity["issuance_pk"], new_version, bytes(prev_hash), role_entries, + bbs_pk, identity["issuance_pk"], new_version, prev_hash, role_entries, ) new_cert = new_state.certify(admin_cred) @@ -283,16 +386,60 @@ def update_registry(userid: str, server: str, registry_id_hex: str, add_roles: l "registry_id": registry_id_hex, "state_bytes_b64": _b64(new_state.serialize()), "state_cert_b64": _b64(bytes(new_cert)), - }) + }, auth_registry_id=registry_id_hex, admin_cred=admin_cred) admin_data["roles"] = all_roles + for name in all_roles: + role_epochs.setdefault(name, 1) + admin_data["role_epochs"] = role_epochs + admin_data["last_known_version"] = new_version + admin_data["last_known_state_hash_b64"] = _b64(new_state.state_hash()) + store.save_admin(userid, registry_id_hex, admin_data) + + +def revoke_registry(userid: str, server: str, registry_id_hex: str, role_name: str | None = None): + admin_data, bbs_pk, admin_cred, prev_hash, new_version = _validated_admin_update_base( + userid, server, registry_id_hex, + ) + identity = store.load_identity(userid) + roles = admin_data.get("roles", []) + if not roles: + raise RuntimeError("registry has no roles to revoke") + role_epochs = _normalize_role_epochs(roles, admin_data.get("role_epochs", {})) + + if role_name is None: + for role in roles: + role_epochs[role] += 1 + else: + if role_name not in roles: + raise RuntimeError(f"role {role_name!r} not in registry (have: {roles})") + role_epochs[role_name] += 1 + + role_entries = [(zkac.role_id(name), bbs_pk, role_epochs[name]) for name in roles] + new_state = zkac.RegistryState.build( + bbs_pk, identity["issuance_pk"], new_version, prev_hash, role_entries, + ) + new_cert = new_state.certify(admin_cred) + + _mgmt_single(userid, server, { + "cmd": "update_registry", + "registry_id": registry_id_hex, + "state_bytes_b64": _b64(new_state.serialize()), + "state_cert_b64": _b64(bytes(new_cert)), + }, auth_registry_id=registry_id_hex, admin_cred=admin_cred) + + admin_data["role_epochs"] = role_epochs + admin_data["last_known_version"] = new_version + admin_data["last_known_state_hash_b64"] = _b64(new_state.state_hash()) store.save_admin(userid, registry_id_hex, admin_data) def get_registry(userid: str, server: str, registry_id_hex: str) -> dict: + admin_data = store.load_admin(userid, registry_id_hex) + _bbs_issuer, _bbs_pk, admin_cred = store.reconstruct_admin(admin_data) return _mgmt_single(userid, server, { "cmd": "get_registry", "registry_id": registry_id_hex, - }) + }, auth_registry_id=registry_id_hex, admin_cred=admin_cred) def list_own_registries(userid: str) -> list[dict]: @@ -307,8 +454,16 @@ def list_own_registries(userid: str) -> list[dict]: return result -def grant(userid: str, server: str, registry_id_hex: str, role_name: str, - recipient_pk_hex: str) -> tuple[str, int]: +def grant_p2p( + userid: str, + server: str, + registry_id_hex: str, + role_name: str, + recipient_pk_hex: str, + recipient_grant_token_b64: str, + peer: str, + peer_transport_pk_hex: str, +) -> dict: admin_data = store.load_admin(userid, registry_id_hex) roles = admin_data.get("roles", []) if role_name not in roles: @@ -316,7 +471,13 @@ def grant(userid: str, server: str, registry_id_hex: str, role_name: str, bbs_issuer, bbs_pk, admin_cred = store.reconstruct_admin(admin_data) role_rid = zkac.role_id(role_name) - epoch = 1 + role_epochs = admin_data.get("role_epochs", {}) + if not isinstance(role_epochs, dict) or role_name not in role_epochs: + raise RuntimeError( + f"missing epoch metadata for role {role_name!r}; " + "refresh local admin metadata before granting" + ) + epoch = int(role_epochs[role_name]) req = zkac.prepare_blind_request() blind_sig = bbs_issuer.issue_blind(req.commitment_with_proof(), role_rid, epoch) @@ -334,172 +495,120 @@ def grant(userid: str, server: str, registry_id_hex: str, role_name: str, recipient_pk = bytes.fromhex(recipient_pk_hex) eph_kp = zkac.IssuanceKeypair() ciphertext = eph_kp.encrypt(recipient_pk, payload) - to_tag = zkac.grant_detection_tag(eph_kp.secret_bytes(), recipient_pk) - - sock, framed = _mgmt_connect(userid, server) + reg = _mgmt_single( + userid, + server, + {"cmd": "get_registry", "registry_id": registry_id_hex}, + auth_registry_id=registry_id_hex, + admin_cred=admin_cred, + ) + host, port = _parse_server(peer) + peer_transport_pk = zkac.PublicKey.from_bytes(bytes.fromhex(peer_transport_pk_hex)) + sock = _connect(host, port, connect_timeout_s=DEFAULT_CONNECT_TIMEOUT_S) try: - transcript_hash = bytes(framed.session.transcript_hash()) + session = client_handshake_anon(sock, _local_node(userid), peer_transport_pk) + framed = FramedSession(sock, session) + transcript_hash = bytes(session.transcript_hash()) admin_proof = admin_cred.present(transcript_hash) - resp = _ok(_mgmt_cmd(framed, { - "cmd": "post_grant", + resp = _ok(json.loads(framed.recv())) + if resp.get("op") != "ready_for_grant": + raise RuntimeError("peer did not accept grant session") + framed.send(json.dumps({ + "op": "p2p_grant", + "grant_token_b64": recipient_grant_token_b64, "registry_id": registry_id_hex, + "registry_state_bytes_b64": reg["state_bytes_b64"], + "registry_state_cert_b64": reg["state_cert_b64"], + "role_name": role_name, "eph_pk_b64": _b64(eph_kp.public_key_bytes()), "ciphertext_b64": _b64(ciphertext), - "to_tag_b64": _b64(to_tag), "admin_proof_b64": _b64(admin_proof), - })) + }).encode()) + ack = _ok(json.loads(framed.recv())) finally: sock.close() - - return "inline-pir-row", resp.get("pool_index", -1) + return {"status": ack.get("status", "ok"), "peer": peer} -def _match_tags(userid: str, tags: list[dict]) -> list[int]: - """Return pool indices whose detection tag matches our issuance key.""" - identity = store.load_identity(userid) - receiver_sk = identity["issuance_sk"] - matches = [] - for idx, entry in enumerate(tags): - eph_pk_b64 = entry.get("eph_pk_b64", "") - to_tag_b64 = entry.get("to_tag_b64", "") - if not eph_pk_b64 or not to_tag_b64: - continue - eph_pk = _unb64(eph_pk_b64) - expected = zkac.grant_detection_tag(receiver_sk, eph_pk) - if _unb64(to_tag_b64) == bytes(expected): - matches.append(idx) - return matches - - -def list_pending(userid: str, server: str) -> list[dict]: - """Discover pending grants via detection tags, then PIR-fetch full rows.""" - identity = store.load_identity(userid) - receiver_kp = zkac.IssuanceKeypair.from_secret(identity["issuance_sk"]) - - info = _mgmt_single(userid, server, {"cmd": "server_info"}) - store.pin_server(userid, server, info["server_public_key_b64"]) - - sock, framed = _mgmt_connect(userid, server) +def receive_p2p(userid: str, host: str, port: int, *, timeout_s: float = 60.0) -> dict: + ident = store.load_identity(userid) + receiver_kp = zkac.IssuanceKeypair.from_secret(ident["issuance_sk"]) + expected_grant_token_b64 = _b64(ident["grant_token"]) + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind((host, port)) + listener.listen(1) + listener.settimeout(timeout_s) + deadline = time.monotonic() + timeout_s try: - tags_resp = _ok(_mgmt_cmd(framed, {"cmd": "pool_tags"})) - tags = tags_resp["tags"] - matches = _match_tags(userid, tags) - - if not matches: - return [] - - pir_cl, pv = _pir_client(userid, framed, server) - results = [] - for idx in matches: + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise RuntimeError("timed out waiting for authenticated grant sender") + listener.settimeout(remaining) + conn, _addr = listener.accept() try: - row = _fetch_row(userid, framed, server, pir_cl, pv, idx) - except Exception: - continue - if row.get("claimed"): - continue - try: - eph_pk = _unb64(row["eph_pk_b64"]) - ct = _unb64(row["ciphertext_b64"]) - plaintext = json.loads(receiver_kp.decrypt(eph_pk, ct)) - results.append({ - "pool_index": idx, - "registry_id": plaintext.get("registry_id", "?"), - "role_name": plaintext.get("role_name", "?"), - }) - except Exception: - results.append({ - "pool_index": idx, - "registry_id": "?", - "role_name": "(undecryptable)", - }) - return results - finally: - sock.close() - - -def collect( - userid: str, - spec: str, - *, - pool_index: int | None = None, -) -> dict: - server, registry_id_hex, role_name = parse_spec(spec) - identity = store.load_identity(userid) - receiver_kp = zkac.IssuanceKeypair.from_secret(identity["issuance_sk"]) - - info = _mgmt_single(userid, server, {"cmd": "server_info"}) - store.pin_server(userid, server, info["server_public_key_b64"]) - - sock, framed = _mgmt_connect(userid, server) - try: - if pool_index is None: - tags_resp = _ok(_mgmt_cmd(framed, {"cmd": "pool_tags"})) - tags = tags_resp["tags"] - matches = _match_tags(userid, tags) - if not matches: - raise RuntimeError("no matching grants found in pool") - - pir_cl, pv = _pir_client(userid, framed, server) - found = None - for idx in matches: - try: - row = _fetch_row(userid, framed, server, pir_cl, pv, idx) - except Exception: - continue - if row.get("claimed"): - continue - try: - eph_pk = _unb64(row["eph_pk_b64"]) - ct = _unb64(row["ciphertext_b64"]) - plaintext = json.loads(receiver_kp.decrypt(eph_pk, ct)) - except Exception: - continue - if (plaintext.get("registry_id") == registry_id_hex and - plaintext.get("role_name") == role_name): - found = (idx, row, plaintext) - break - if found is None: - raise RuntimeError( - f"no unclaimed grant for {registry_id_hex}:{role_name} in pool" - ) - pool_index, target_row, target_payload = found - else: - pir_cl, pv = _pir_client(userid, framed, server) - target_row = _fetch_row(userid, framed, server, pir_cl, pv, pool_index) - if target_row.get("claimed"): - raise RuntimeError("grant row is already claimed") - try: - eph_pk = _unb64(target_row["eph_pk_b64"]) - ct = _unb64(target_row["ciphertext_b64"]) - target_payload = json.loads(receiver_kp.decrypt(eph_pk, ct)) - except Exception as exc: - raise RuntimeError("PIR row did not decrypt for this user") from exc - if (target_payload.get("registry_id") != registry_id_hex or - target_payload.get("role_name") != role_name): - raise RuntimeError( - "PIR row does not match this collect spec" + conn.settimeout(min(remaining, 30.0)) + session = server_handshake_anon(conn, _local_node(userid)) + framed = FramedSession(conn, session) + framed.send(json.dumps({"ok": True, "op": "ready_for_grant"}).encode()) + msg = _ok(json.loads(framed.recv())) + if msg.get("op") != "p2p_grant": + raise RuntimeError("unexpected p2p message") + if msg.get("grant_token_b64") != expected_grant_token_b64: + raise RuntimeError("grant pairing token mismatch") + registry_id_hex = msg["registry_id"] + expected_role_name = msg.get("role_name") + if not isinstance(expected_role_name, str) or not expected_role_name: + raise RuntimeError("grant message missing required role_name") + state_bytes = _unb64(msg["registry_state_bytes_b64"]) + state_cert = _unb64(msg["registry_state_cert_b64"]) + mgr = zkac.RegistryManager() + restored_registry_id = mgr.restore(state_bytes, state_cert).hex() + if restored_registry_id != registry_id_hex: + raise RuntimeError("registry snapshot does not match announced registry_id") + if not mgr.verify_admin( + bytes.fromhex(registry_id_hex), + _unb64(msg["admin_proof_b64"]), + bytes(session.transcript_hash()), + ): + raise RuntimeError("sender admin proof failed") + payload = json.loads( + receiver_kp.decrypt(_unb64(msg["eph_pk_b64"]), _unb64(msg["ciphertext_b64"])) ) + if payload["registry_id"] != registry_id_hex: + raise RuntimeError("grant payload registry_id does not match authenticated registry") + if expected_role_name and payload["role_name"] != expected_role_name: + raise RuntimeError("grant payload role does not match announced role") + cred_data = { + "blind_sig_b64": payload["blind_sig_b64"], + "member_secret_b64": payload["member_secret_b64"], + "prover_blind_b64": payload["prover_blind_b64"], + "role_name": payload["role_name"], + "epoch": payload["epoch"], + "issuer_pk_b64": payload["issuer_pk_b64"], + } + cred = store.reconstruct_credential(cred_data) + nonce = bytes(session.transcript_hash()) + cred_proof = cred.present(nonce) + role_id = zkac.role_id(payload["role_name"]) + if not mgr.verify_presentation( + bytes.fromhex(registry_id_hex), + role_id, + cred_proof, + nonce, + ): + raise RuntimeError("grant credential does not verify against certified registry state") + store.save_credential(userid, registry_id_hex, payload["role_name"], cred_data) + framed.send(json.dumps({"ok": True, "status": "stored"}).encode()) + return {"registry_id": registry_id_hex, "role": payload["role_name"]} + except (RuntimeError, ValueError, KeyError, json.JSONDecodeError): + # Keep listening until timeout; this prevents first-connector DoS. + continue + finally: + conn.close() finally: - sock.close() - - _ = _mgmt_single(userid, server, { - "cmd": "get_registry", "registry_id": registry_id_hex, - }) - - cred_data = { - "blind_sig_b64": target_payload["blind_sig_b64"], - "member_secret_b64": target_payload["member_secret_b64"], - "prover_blind_b64": target_payload["prover_blind_b64"], - "role_name": role_name, - "epoch": target_payload["epoch"], - "issuer_pk_b64": target_payload["issuer_pk_b64"], - } - cred = store.reconstruct_credential(cred_data) - cred.present(b"self-test") - - store.save_credential(userid, registry_id_hex, role_name, cred_data) - - return {"registry_id": registry_id_hex, "role": role_name, "server": server} + listener.close() def authenticate(userid: str, registry_id_hex: str, role_name: str, @@ -523,7 +632,7 @@ def authenticate(userid: str, registry_id_hex: str, role_name: str, node = zkac.Node(zkac.Keypair()) host, port = _parse_server(server) - sock = socket.create_connection((host, port)) + sock = _connect(host, port, connect_timeout_s=DEFAULT_CONNECT_TIMEOUT_S) try: session = client_handshake_anon(sock, node, server_pk) framed = FramedSession(sock, session) diff --git a/cli/zkac_cli/i2p_serve.py b/cli/zkac_cli/i2p_serve.py new file mode 100644 index 0000000..b26b7f8 --- /dev/null +++ b/cli/zkac_cli/i2p_serve.py @@ -0,0 +1,83 @@ +"""Entry point for ``zkac-node-i2p-server``: same TCP node as ``zkac-node serve``, for I2P tunnels.""" + +from __future__ import annotations + +import argparse +import sys + +from .paths import user_dir +from .server import serve + + +def main() -> None: + epilog = """ +I2P setup (typical): + 1. Run this command so the node listens on the loopback address shown below. + 2. In I2P or i2pd, create a server tunnel (TCP) that forwards inbound I2P + connections to that host:port (e.g. 127.0.0.1:9800). + 3. Note the published destination (…b32.i2p) and the tunnel’s virtual port. + Clients use: .b32.i2p: as the server argument to + zkac-node (registry, grant, auth, net check, etc.). + 4. Clients must reach I2P (e.g. export ZKAC_SOCKS5_PROXY=127.0.0.1:4447). + +Pins are stored per host:port string — clients should pin the same address they +use to connect (the .b32.i2p:port form, not localhost). +""" + + p = argparse.ArgumentParser( + prog="zkac-node-i2p-server", + description=( + "Run the ZKAC node (registry + anonymous handshake) on TCP for exposure " + "through an I2P server tunnel. This is zkac-node serve with I2P-oriented " + "defaults and setup hints." + ), + epilog=epilog, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p.add_argument( + "userid", + help="user whose ~/.zkac//server/ holds server state (unless --data-dir)", + ) + p.add_argument("--data-dir", default=None, help="override server data directory") + p.add_argument( + "--host", + default="127.0.0.1", + help="bind address (default: 127.0.0.1 — forward here from your I2P tunnel)", + ) + p.add_argument( + "--port", + type=int, + default=9800, + help="TCP port the node listens on (default: 9800)", + ) + p.add_argument( + "--no-i2p-banner", + action="store_true", + help="suppress I2P tunnel reminder text", + ) + p.add_argument( + "--allow-non-loopback", + action="store_true", + help="allow --host values outside loopback (default blocks non-loopback binds)", + ) + args = p.parse_args() + + data_dir = args.data_dir + if data_dir is None: + data_dir = str(user_dir(args.userid) / "server") + + if not args.no_i2p_banner: + print( + "zkac-node-i2p-server: listening for plain TCP (expose this with an I2P server tunnel).\n" + f" bind: {args.host}:{args.port}\n" + f" data: {data_dir}\n" + "Clients on I2P: set ZKAC_SOCKS5_PROXY if needed, then use host:port like " + "mydest.b32.i2p:9800 with zkac-node commands.\n", + file=sys.stderr, + ) + + serve(data_dir, args.host, args.port, allow_non_loopback=args.allow_non_loopback) + + +if __name__ == "__main__": + main() diff --git a/cli/zkac_cli/main.py b/cli/zkac_cli/main.py index 26be3c6..6990e94 100644 --- a/cli/zkac_cli/main.py +++ b/cli/zkac_cli/main.py @@ -11,6 +11,11 @@ from . import client, store from .paths import user_dir +def _is_loopback_host(host: str) -> bool: + value = host.strip().lower() + return value in {"127.0.0.1", "::1", "localhost"} + + # ── user ────────────────────────────────────────────────────────────── def _cmd_user_create(args): @@ -18,6 +23,7 @@ def _cmd_user_create(args): ident = store.load_identity(args.userid) print(f"created user {args.userid!r}") print(f" issuance public key: {ident['issuance_pk'].hex()}") + print(f" p2p transport public key: {ident['transport_pk'].hex()}") print(f" (share this key out-of-band to receive credentials)") print(f" stored in {path}") @@ -33,8 +39,14 @@ def _cmd_user_list(_args): def _cmd_user_show(args): ident = store.load_identity(args.userid) + contact = store.export_contact_bundle(args.userid, peer=args.peer) print(f"user: {args.userid}") print(f" issuance pk: {ident['issuance_pk'].hex()}") + print(f" p2p transport pk: {ident['transport_pk'].hex()}") + print(" share contact:") + print(f" {contact}") + if args.peer: + print(f" contact peer endpoint: {args.peer}") owned = [ {"registry_id": r, **store.load_admin(args.userid, r)} @@ -59,7 +71,15 @@ def _cmd_serve(args): data_dir = args.data_dir if data_dir is None: data_dir = str(user_dir(args.userid) / "server") - serve(data_dir, args.host, args.port) + serve( + data_dir, + args.host, + args.port, + max_connections=args.max_connections, + idle_timeout_s=args.idle_timeout, + listen_backlog=args.listen_backlog, + allow_non_loopback=args.allow_non_loopback, + ) # ── server pin ─────────────────────────────────────────────────────── @@ -101,24 +121,49 @@ def _cmd_registry_list(args): print(f" {r['registry_id']} @ {r['server']} roles={r['roles']}") +def _cmd_registry_revoke(args): + if args.all and args.role: + raise RuntimeError("use either --role or --all, not both") + if not args.all and not args.role: + raise RuntimeError("missing target: pass --role or --all") + client.revoke_registry( + args.userid, + args.server, + args.registry, + role_name=None if args.all else args.role, + ) + if args.all: + print(f"registry revoked: {args.registry[:16]}…") + print(" bumped epoch for all roles") + else: + print(f"registry revoked: {args.registry[:16]}…") + print(f" bumped epoch for role: {args.role}") + + # ── grant ───────────────────────────────────────────────────────────── def _cmd_grant(args): - _row_mode, pool_index = client.grant( - args.userid, args.server, args.registry, args.role, args.to, - ) - print(f"granted {args.role!r} to {args.to[:16]}…") - print(" delivery: inline PIR row (no grant-id follow-up fetch)") - print(f" pool index: {pool_index}") - print(f" recipient can collect with:") - print( - f" zkac-node collect {args.server}:{args.registry}:{args.role}" - ) - print(f" or with explicit index:") - print( - f" zkac-node collect {args.server}:{args.registry}:{args.role} " - f"--pool-index {pool_index}" + parsed = store.parse_contact_bundle(args.to) + to = parsed["issuance_pk_hex"] + peer_key = parsed["transport_pk_hex"] + grant_token = parsed.get("grant_token_b64") + peer = parsed.get("peer") + if not grant_token: + raise RuntimeError( + "contact bundle is missing grant pairing token. " + "Ask recipient to regenerate bundle with current CLI." + ) + if not peer: + raise RuntimeError( + "contact bundle is missing peer endpoint. " + "Ask recipient to regenerate with: zkac-node user show --peer " + ) + + result = client.grant_p2p( + args.userid, args.server, args.registry, args.role, to, grant_token, peer, peer_key, ) + print(f"granted {args.role!r} to {to[:16]}…") + print(f" delivery: direct p2p ({result['peer']})") # ── credentials / collect ─────────────────────────────────────────── @@ -131,48 +176,22 @@ def _cmd_credentials_list(args): for reg_hex, role in local: print(f" {reg_hex}:{role}") - servers = list(args.server or []) - for s in store.known_servers(args.userid): - if s not in servers: - servers.append(s) - - if not servers: - print("\n(no servers to query; pass --server host:port to check for pending)") - return - - print("\npending grants:") - any_pending = False - for srv in servers: - try: - grants = client.list_pending(args.userid, srv) - except Exception as exc: - print(f" [{srv}] error: {exc}") - continue - for g in grants: - any_pending = True - rid = g.get("registry_id", "?") - role = g.get("role_name", "?") - pidx = g.get("pool_index") - idx_s = f" idx={pidx}" if pidx is not None else "" - if rid != "?" and role != "?" and store.has_credential(args.userid, rid, role): - note = " (already collected locally)" - else: - note = "" - print(f" {srv}:{rid}:{role}{idx_s}{note}") - if not any_pending: - print(" (none)") + print("\npending grants: removed (use direct p2p listener)") -def _cmd_collect(args): - result = client.collect( - args.userid, - args.spec, - pool_index=args.pool_index, - ) - print("collected credential") +def _cmd_p2p_listen(args): + if not _is_loopback_host(args.host): + if not args.allow_non_loopback: + raise RuntimeError( + "refusing to bind p2p-listen outside loopback. " + "Use --allow-non-loopback only when exposure is intentional." + ) + print(f"warning: binding p2p-listen outside loopback: {args.host}:{args.port}", file=sys.stderr) + print(f"listening for p2p grant on {args.host}:{args.port}") + result = client.receive_p2p(args.userid, args.host, args.port, timeout_s=args.timeout) + print("received credential") print(f" registry: {result['registry_id']}") print(f" role: {result['role']}") - print(f" server: {result['server']}") # ── auth ────────────────────────────────────────────────────────────── @@ -184,6 +203,28 @@ def _cmd_auth(args): print(json.dumps(resp, indent=2)) +def _cmd_net_check(args): + resp = client.net_check( + args.target, + timeout_s=args.timeout, + handshake=args.handshake, + userid=args.userid, + server_key_hex=args.key, + ) + if resp.get("ok"): + print("network check: ok") + print(f" target: {resp['target']}") + print(f" via: {resp['via']}") + if args.handshake: + print(" handshake: ok") + return + print("network check: failed") + print(f" target: {resp['target']}") + print(f" via: {resp['via']}") + print(f" error: {resp.get('error', 'unknown')}") + sys.exit(2) + + # ── argparse ───────────────────────────────────────────────────────── def main(): @@ -203,6 +244,7 @@ def main(): c = user_sub.add_parser("show", help="show user keys + registries + credentials") c.add_argument("userid") + c.add_argument("--peer", default=None, help="optional recipient p2p host:port to embed in contact bundle") c.set_defaults(func=_cmd_user_show) # serve @@ -211,6 +253,14 @@ def main(): c.add_argument("--data-dir", default=None, help="override server data directory") c.add_argument("--host", default="127.0.0.1") c.add_argument("--port", type=int, default=9800) + c.add_argument("--max-connections", type=int, default=64, help="max concurrent client connections") + c.add_argument("--idle-timeout", type=float, default=45.0, help="per-connection idle timeout seconds") + c.add_argument("--listen-backlog", type=int, default=64, help="TCP listen backlog") + c.add_argument( + "--allow-non-loopback", + action="store_true", + help="allow --host values outside loopback (default blocks non-loopback binds)", + ) c.set_defaults(func=_cmd_serve) # server pin @@ -250,13 +300,21 @@ def main(): c.add_argument("userid") c.set_defaults(func=_cmd_registry_list) + c = reg_sub.add_parser("revoke", help="revoke credentials by bumping role epoch(s)") + c.add_argument("userid") + c.add_argument("server", help="host:port") + c.add_argument("--registry", required=True) + c.add_argument("--role", default=None, help="revoke a single role by bumping its epoch") + c.add_argument("--all", action="store_true", help="revoke all roles by bumping all epochs") + c.set_defaults(func=_cmd_registry_revoke) + # grant c = sub.add_parser("grant", help="issue a credential to a recipient (admin)") c.add_argument("userid") c.add_argument("--server", required=True, help="host:port") c.add_argument("--registry", required=True) c.add_argument("--role", required=True) - c.add_argument("--to", required=True, help="recipient issuance public key (hex)") + c.add_argument("--to", required=True, help="recipient contact bundle") c.set_defaults(func=_cmd_grant) # credentials @@ -264,20 +322,20 @@ def main(): cred_sub = cred_p.add_subparsers(dest="action", required=True) c = cred_sub.add_parser("list", help="show local + pending credentials") c.add_argument("userid") - c.add_argument("--server", action="append", help="server to query (host:port); repeatable") c.set_defaults(func=_cmd_credentials_list) - # collect - c = sub.add_parser("collect", help="fetch and finalize a pending credential") + # direct p2p receive + c = sub.add_parser("p2p-listen", help="listen for one direct p2p credential grant") c.add_argument("userid") - c.add_argument("spec", help="host:port:registry_id:role") + c.add_argument("--host", default="127.0.0.1") + c.add_argument("--port", type=int, default=9810) + c.add_argument("--timeout", type=float, default=60.0, help="max wait for authenticated sender") c.add_argument( - "--pool-index", - type=int, - default=None, - help="grant row index (optional; auto-discovered via detection tags if omitted)", + "--allow-non-loopback", + action="store_true", + help="allow --host values outside loopback (default blocks non-loopback binds)", ) - c.set_defaults(func=_cmd_collect) + c.set_defaults(func=_cmd_p2p_listen) # auth c = sub.add_parser("auth", help="authenticate with a credential") @@ -287,6 +345,17 @@ def main(): c.add_argument("--server", default=None, help="host:port (optional if known from registry)") c.set_defaults(func=_cmd_auth) + # network diagnostics + net_p = sub.add_parser("net", help="network diagnostics") + net_sub = net_p.add_subparsers(dest="action", required=True) + c = net_sub.add_parser("check", help="test TCP reachability (direct or SOCKS5 proxy)") + c.add_argument("target", help="host:port (supports .b32.i2p via SOCKS5)") + c.add_argument("--timeout", type=float, default=8.0, help="connect timeout in seconds") + c.add_argument("--handshake", action="store_true", help="also run anonymous ZKAC handshake") + c.add_argument("--userid", default=None, help="client userid to load pinned server key for --handshake") + c.add_argument("--key", default=None, help="server public key hex for --handshake") + c.set_defaults(func=_cmd_net_check) + args = p.parse_args() if not hasattr(args, "func"): p.print_help() diff --git a/cli/zkac_cli/server.py b/cli/zkac_cli/server.py index e408350..293e6f4 100644 --- a/cli/zkac_cli/server.py +++ b/cli/zkac_cli/server.py @@ -1,40 +1,20 @@ -"""ZKAC server: all traffic over a single encrypted, server-authenticated channel. - -Every connection performs an anonymous handshake (X25519 + Schnorr server -identity proof). The first encrypted frame selects the mode: - - {"op": "mgmt"} management commands (create_registry, post_grant, ...) - {"op": "auth", "registry_id": hex, "bbs_auth_b64": ...} role authentication - -The server stores only cryptographically verified opaque blobs: - /server_key.json Schnorr keypair - /registries/.state raw RegistryState bytes - /registries/.cert raw state cert bytes - /mailbox/grants_pool.json anonymous append-only grant pool - - Recipients discover matching grants via a cheap detection-tag index - (``pool_tags``). PIR (``pir_query``) returns a full encrypted mailbox row, - so no follow-up ``grant_id`` fetch is required. This avoids leaking a stable - row identifier during retrieval. - Large PIR answers are streamed in slices (same pattern as ``pir_hints``). -""" +"""ZKAC server for registry management and role authentication.""" from __future__ import annotations import base64 -import hashlib import json +import os import socket +import sys import threading import traceback from pathlib import Path import zkac -from zkac.tcp import MAX_TCP_FRAME_BYTES, FramedSession, server_handshake_anon +from zkac.tcp import FramedSession, server_handshake_anon -# Serialized PIR hints can be huge (64 KiB records × LWE width). Each mgmt reply must -# stay under :data:`MAX_TCP_FRAME_BYTES` after encryption; base64 expands ~4/3. -_PIR_HINT_CHUNK = min(131_072, max(16_384, (MAX_TCP_FRAME_BYTES * 3) // 5)) +from .server_debug import ServerDebugState def _b64(data: bytes) -> str: @@ -45,42 +25,35 @@ def _unb64(s: str) -> bytes: return base64.b64decode(s) -def _pir_row_bytes(entry: dict) -> bytes: - """Fixed-size PIR row: full encrypted mailbox row + ciphertext digest.""" - ct_b64 = entry.get("ciphertext_b64", "") - ct_digest = hashlib.sha256(_unb64(ct_b64)).hexdigest() if ct_b64 else "" - row = { - "v": 2, - "eph_pk_b64": entry.get("eph_pk_b64", ""), - "to_tag_b64": entry.get("to_tag_b64", ""), - "ciphertext_b64": ct_b64, - "ciphertext_sha256": ct_digest, - "claimed": bool(entry.get("claimed", False)), - } - raw = json.dumps(row, separators=(",", ":"), sort_keys=True).encode("utf-8") - if len(raw) > zkac.PIR_RECORD_BYTES: - raise ValueError( - f"PIR row exceeds PIR_RECORD_BYTES ({zkac.PIR_RECORD_BYTES})" - ) - return raw + b"\x00" * (zkac.PIR_RECORD_BYTES - len(raw)) +def _chmod_if_possible(path: Path, mode: int): + try: + os.chmod(path, mode) + except OSError: + pass + + +def _write_private_json(path: Path, payload: dict): + path.write_text(json.dumps(payload, indent=2)) + _chmod_if_possible(path, 0o600) + + +def _is_loopback_host(host: str) -> bool: + value = host.strip().lower() + return value in {"127.0.0.1", "::1", "localhost"} # ── Opaque server storage ───────────────────────────────────────────── class _ServerStore: - """Thread-safe, opaque persistence for registries and anonymous grant pool.""" + """Thread-safe, opaque persistence for registry snapshots.""" def __init__(self, data_dir: Path): self._dir = data_dir self._reg_dir = data_dir / "registries" - self._mbox_dir = data_dir / "mailbox" - self._pool_path = self._mbox_dir / "grants_pool.json" self._reg_dir.mkdir(parents=True, exist_ok=True) - self._mbox_dir.mkdir(parents=True, exist_ok=True) + _chmod_if_possible(self._dir, 0o700) + _chmod_if_possible(self._reg_dir, 0o700) self._lock = threading.Lock() - self._pir_server: zkac.PirServer | None = None - self._pir_dirty = True - self._migrate_legacy_mailbox() # ── server key ──────────────────────────────────────────────────── @@ -90,10 +63,10 @@ class _ServerStore: data = json.loads(kf.read_text()) return zkac.Keypair.from_secret_key(_unb64(data["secret_b64"])) kp = zkac.Keypair() - kf.write_text(json.dumps({ + _write_private_json(kf, { "secret_b64": _b64(kp.secret_key_bytes()), "public_b64": _b64(kp.public_key().to_bytes()), - }, indent=2)) + }) return kp # ── registries ──────────────────────────────────────────────────── @@ -117,105 +90,6 @@ class _ServerStore: print(f"[server] skip registry {rid_hex}: {exc}") return count - # ── legacy per-recipient mailbox → anonymous pool ───────────────── - - def _migrate_legacy_mailbox(self): - if self._pool_path.exists(): - return - records: list[dict] = [] - for p in sorted(self._mbox_dir.glob("*.json")): - if p.name == "grants_pool.json": - continue - try: - entries = json.loads(p.read_text()) - for e in entries: - if "claimed" not in e: - e["claimed"] = False - records.append(e) - except Exception as exc: - print(f"[server] skip legacy mailbox {p.name}: {exc}") - try: - p.unlink() - except OSError: - pass - if records: - self._pool_path.write_text( - json.dumps({"version": 1, "records": records}, indent=2) - ) - - def _load_pool(self) -> list[dict]: - if not self._pool_path.exists(): - return [] - data = json.loads(self._pool_path.read_text()) - return data.get("records", []) - - def _save_pool(self, records: list[dict]): - self._pool_path.write_text( - json.dumps({"version": 1, "records": records}, indent=2) - ) - - # ── PIR server rebuild ──────────────────────────────────────────── - - def _ensure_pir(self) -> zkac.PirServer: - if self._pir_server is not None and not self._pir_dirty: - return self._pir_server - records = self._load_pool() - packed = [_pir_row_bytes(r) for r in records] - db = zkac.PirDatabase(packed, zkac.PIR_RECORD_BYTES) - self._pir_server = zkac.PirServer(db) - self._pir_dirty = False - return self._pir_server - - # ── anonymous grant pool ───────────────────────────────────────── - - def post_grant(self, entry: dict) -> int: - _pir_row_bytes(entry) - row = {"claimed": False, **entry} - with self._lock: - records = self._load_pool() - pool_index = len(records) - records.append(row) - self._save_pool(records) - self._pir_dirty = True - return pool_index - - def pool_info(self) -> dict: - with self._lock: - pir = self._ensure_pir() - return { - "n": pir.n_records, - "record_bytes": pir.record_bytes, - "pool_version": bytes(pir.version()).hex(), - } - - def pool_tags(self) -> tuple[list[tuple[str, str]], str]: - with self._lock: - records = self._load_pool() - pir = self._ensure_pir() - version = bytes(pir.version()).hex() - tags = [] - for r in records: - tags.append(( - r.get("eph_pk_b64", ""), - r.get("to_tag_b64", ""), - )) - return tags, version - - def pir_hints(self) -> tuple[bytes, str]: - with self._lock: - pir = self._ensure_pir() - return bytes(pir.hints()), bytes(pir.version()).hex() - - def pir_answer_bytes(self, query_b64: str, pool_version: str) -> bytes | None: - """Return raw PIR answer bytes, or ``None`` if ``pool_version`` is stale.""" - with self._lock: - pir = self._ensure_pir() - current = bytes(pir.version()).hex() - if current != pool_version: - return None - ans = pir.answer(_unb64(query_b64)) - return bytes(ans) - # ── Command dispatch (inside encrypted session) ────────────────────── def _dispatch( @@ -228,6 +102,20 @@ def _dispatch( ) -> dict: try: action = cmd.get("cmd") + rid_hex = cmd.get("auth_registry_id") + admin_proof_b64 = cmd.get("admin_proof_b64") + + def _require_admin_for_registry(target_rid_hex: str): + if rid_hex != target_rid_hex: + raise RuntimeError("auth_registry_id must match command registry_id") + if not isinstance(admin_proof_b64, str) or not admin_proof_b64: + raise RuntimeError("missing admin_proof_b64") + if not mgr.verify_admin( + bytes.fromhex(target_rid_hex), + _unb64(admin_proof_b64), + transcript_hash, + ): + raise RuntimeError("admin authorization failed") if action == "server_info": return {"ok": True, "server_public_key_b64": server_pk_b64} @@ -235,12 +123,29 @@ def _dispatch( if action == "create_registry": state_bytes = _unb64(cmd["state_bytes_b64"]) state_cert = _unb64(cmd["state_cert_b64"]) + auth_rid = cmd.get("auth_registry_id") + if not isinstance(auth_rid, str): + raise RuntimeError("missing auth_registry_id") + if not isinstance(admin_proof_b64, str) or not admin_proof_b64: + raise RuntimeError("missing admin_proof_b64") + tmp_mgr = zkac.RegistryManager() + expected_rid = tmp_mgr.create(state_bytes, state_cert).hex() + if expected_rid != auth_rid: + raise RuntimeError("auth_registry_id does not match certified state") + if not tmp_mgr.verify_admin( + bytes.fromhex(expected_rid), + _unb64(admin_proof_b64), + transcript_hash, + ): + raise RuntimeError("admin authorization failed for create_registry") rid = mgr.create(state_bytes, state_cert) store.save_registry(rid.hex(), state_bytes, state_cert) return {"ok": True, "registry_id": rid.hex()} if action == "get_registry": - rid = bytes.fromhex(cmd["registry_id"]) + rid_hex_cmd = cmd["registry_id"] + _require_admin_for_registry(rid_hex_cmd) + rid = bytes.fromhex(rid_hex_cmd) state_bytes, state_cert = mgr.get(rid) return { "ok": True, @@ -249,117 +154,15 @@ def _dispatch( } if action == "update_registry": - rid = bytes.fromhex(cmd["registry_id"]) + rid_hex_cmd = cmd["registry_id"] + _require_admin_for_registry(rid_hex_cmd) + rid = bytes.fromhex(rid_hex_cmd) state_bytes = _unb64(cmd["state_bytes_b64"]) state_cert = _unb64(cmd["state_cert_b64"]) mgr.update(rid, state_bytes, state_cert) - store.save_registry(cmd["registry_id"], state_bytes, state_cert) + store.save_registry(rid_hex_cmd, state_bytes, state_cert) return {"ok": True} - if action == "post_grant": - rid = bytes.fromhex(cmd["registry_id"]) - proof = _unb64(cmd["admin_proof_b64"]) - if not mgr.verify_admin(rid, proof, transcript_hash): - return {"error": "admin proof failed"} - entry = { - "eph_pk_b64": cmd["eph_pk_b64"], - "ciphertext_b64": cmd["ciphertext_b64"], - "to_tag_b64": cmd.get("to_tag_b64", ""), - } - pool_index = store.post_grant(entry) - return {"ok": True, "pool_index": pool_index} - - if action == "pool_info": - info = store.pool_info() - return {"ok": True, **info} - - if action == "pool_tags": - tags, version = store.pool_tags() - entries = [ - {"eph_pk_b64": epk, "to_tag_b64": tag} - for epk, tag in tags - ] - return {"ok": True, "tags": entries, "pool_version": version} - - if action == "pir_hints": - raw, version = store.pir_hints() - offset = cmd.get("offset") - if offset is not None: - if cmd.get("pool_version") != version: - return {"error": "stale_version"} - try: - off = int(offset) - except (TypeError, ValueError): - return {"error": "bad offset"} - if off < 0 or off > len(raw): - return {"error": "bad offset"} - piece = raw[off : off + _PIR_HINT_CHUNK] - nxt = off + len(piece) - return { - "ok": True, - "pool_version": version, - "slice_b64": _b64(piece), - "offset": off, - "returned": len(piece), - "done": nxt >= len(raw), - } - if len(raw) <= _PIR_HINT_CHUNK: - return {"ok": True, "hints_b64": _b64(raw), "pool_version": version} - return { - "ok": True, - "pool_version": version, - "hints_total": len(raw), - "chunk": _PIR_HINT_CHUNK, - } - - if action == "pir_query": - q_b64 = cmd.get("query_b64", "") - pv = cmd.get("pool_version", "") - offset = cmd.get("offset") - - if offset is None: - ans_bytes = store.pir_answer_bytes(q_b64, pv) - if ans_bytes is None: - return {"error": "stale_version"} - conn_ctx.pop("pir_answer_buffer", None) - conn_ctx.pop("pir_answer_pv", None) - if len(ans_bytes) <= _PIR_HINT_CHUNK: - return {"ok": True, "answer_b64": _b64(ans_bytes), "pool_version": pv} - conn_ctx["pir_answer_buffer"] = ans_bytes - conn_ctx["pir_answer_pv"] = pv - return { - "ok": True, - "pool_version": pv, - "answer_total": len(ans_bytes), - "chunk": _PIR_HINT_CHUNK, - } - - if conn_ctx.get("pir_answer_pv") != pv: - return {"error": "stale_version"} - buf = conn_ctx.get("pir_answer_buffer") - if buf is None: - return {"error": "no PIR answer in progress"} - try: - off = int(offset) - except (TypeError, ValueError): - return {"error": "bad offset"} - if off < 0 or off > len(buf): - return {"error": "bad offset"} - piece = buf[off : off + _PIR_HINT_CHUNK] - nxt = off + len(piece) - done = nxt >= len(buf) - if done: - conn_ctx.pop("pir_answer_buffer", None) - conn_ctx.pop("pir_answer_pv", None) - return { - "ok": True, - "pool_version": pv, - "slice_b64": _b64(piece), - "offset": off, - "returned": len(piece), - "done": done, - } - return {"error": f"unknown command: {action}"} except Exception as exc: @@ -368,26 +171,51 @@ def _dispatch( # ── Connection handler ──────────────────────────────────────────────── -def _handle_conn(conn: socket.socket, addr: tuple, node: zkac.Node, - mgr: zkac.RegistryManager, store: _ServerStore, - server_pk_b64: str): +def _handle_conn( + conn: socket.socket, + addr: tuple, + node: zkac.Node, + mgr: zkac.RegistryManager, + store: _ServerStore, + server_pk_b64: str, + idle_timeout_s: float, + slots: threading.BoundedSemaphore, + debug: ServerDebugState | None = None, +): peer = f"{addr[0]}:{addr[1]}" + cid = debug.open_connection(peer) if debug else None + err: str | None = None try: + conn.settimeout(idle_timeout_s) + if debug and cid: + debug.update_connection(cid, phase="handshake") session = server_handshake_anon(conn, node) framed = FramedSession(conn, session) transcript_hash = bytes(session.transcript_hash()) + if debug and cid: + debug.update_connection( + cid, + phase="post_handshake", + transcript_hash_hex=transcript_hash.hex(), + ) hello = json.loads(framed.recv()) op = hello.get("op") + if debug and cid: + debug.update_connection(cid, phase=f"hello:{op}", hello_op=op) if op == "mgmt": conn_ctx: dict = {} + if debug and cid: + debug.update_connection(cid, phase="mgmt_loop") while True: try: data = framed.recv() except (ConnectionError, OSError): break cmd = json.loads(data) + if debug and cid: + debug.note_mgmt_command(cid, cmd) resp = _dispatch(cmd, mgr, store, server_pk_b64, transcript_hash, conn_ctx) framed.send(json.dumps(resp).encode()) @@ -395,14 +223,25 @@ def _handle_conn(conn: socket.socket, addr: tuple, node: zkac.Node, registry_id = bytes.fromhex(hello["registry_id"]) role_id = bytes.fromhex(hello["role_id"]) proof_bytes = _unb64(hello["bbs_auth_b64"]) + if debug and cid: + debug.update_connection( + cid, + phase="auth_verify", + auth_registry_hex=registry_id.hex(), + auth_role_hex=role_id.hex(), + ) ok = mgr.verify_presentation( registry_id, role_id, proof_bytes, transcript_hash, ) if not ok: + if debug and cid: + debug.update_connection(cid, phase="auth_failed", auth_ok=False) framed.send(json.dumps({"error": "auth failed"}).encode()) return + if debug and cid: + debug.update_connection(cid, phase="auth_ok", auth_ok=True) resp = { "status": "authenticated", "registry_id": registry_id.hex(), @@ -410,52 +249,95 @@ def _handle_conn(conn: socket.socket, addr: tuple, node: zkac.Node, } framed.send(json.dumps(resp).encode()) + if debug and cid: + debug.update_connection(cid, phase="auth_echo_loop") while True: try: data = framed.recv() except (ConnectionError, OSError): break + if debug and cid: + debug.note_echo_chunk(cid, len(data)) framed.send(data) else: + if debug and cid: + debug.update_connection(cid, phase="unknown_op", error=f"op={op!r}") framed.send(json.dumps({"error": f"unknown op: {op}"}).encode()) except (ConnectionError, BrokenPipeError, OSError): pass except Exception as exc: + err = str(exc) + if debug and cid: + debug.update_connection(cid, phase="error", error=err) print(f"[server] {peer} error: {exc}") traceback.print_exc() finally: + if debug and cid: + debug.close_connection(cid, error=err) conn.close() + slots.release() # ── Public entry point ──────────────────────────────────────────────── -def serve(data_dir: str, host: str = "127.0.0.1", port: int = 9800): +def serve( + data_dir: str, + host: str = "127.0.0.1", + port: int = 9800, + max_connections: int = 64, + idle_timeout_s: float = 45.0, + listen_backlog: int = 64, + *, + debug: ServerDebugState | None = None, + allow_non_loopback: bool = False, +): dd = Path(data_dir) dd.mkdir(parents=True, exist_ok=True) store = _ServerStore(dd) kp = store.load_or_create_keypair() server_pk_b64 = _b64(kp.public_key().to_bytes()) + pk_hex = _unb64(server_pk_b64).hex() node = zkac.Node(kp) mgr = zkac.RegistryManager() n = store.load_all_registries(mgr) - print(f"server public key: {_unb64(server_pk_b64).hex()}") + if debug is not None: + debug.set_listen(host, port) + debug.set_boot_info(server_pk_hex=pk_hex, registries_loaded=n) + + print(f"server public key: {pk_hex}") print(f"loaded {n} registries") print(f"listening on {host}:{port}") + if not _is_loopback_host(host): + if not allow_non_loopback: + raise RuntimeError( + "refusing to bind outside loopback. " + "Use --allow-non-loopback only when you intentionally expose this listener." + ) + print( + f"[warning] binding outside loopback: {host}:{port}. " + "Ensure network exposure is intentional.", + file=sys.stderr, + ) + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((host, port)) - sock.listen(8) + slots = threading.BoundedSemaphore(max_connections) + sock.listen(listen_backlog) try: while True: conn, addr = sock.accept() + if not slots.acquire(blocking=False): + conn.close() + continue threading.Thread( target=_handle_conn, - args=(conn, addr, node, mgr, store, server_pk_b64), + args=(conn, addr, node, mgr, store, server_pk_b64, idle_timeout_s, slots, debug), daemon=True, ).start() except KeyboardInterrupt: diff --git a/cli/zkac_cli/server_debug.py b/cli/zkac_cli/server_debug.py new file mode 100644 index 0000000..0553670 --- /dev/null +++ b/cli/zkac_cli/server_debug.py @@ -0,0 +1,186 @@ +"""Mutable debug snapshot for ``zkac-node serve`` (optional, thread-safe).""" + +from __future__ import annotations + +import base64 +import json +import threading +import time +import uuid +from collections import deque +from pathlib import Path +from typing import Any + +import zkac + + +class ServerDebugState: + """ + Live server introspection for an admin dashboard. + + Connection records are updated from worker threads; readers take a consistent + snapshot via :meth:`snapshot`. + """ + + def __init__(self, *, userid: str, data_dir: str) -> None: + self.userid = userid + self.data_dir = str(Path(data_dir).resolve()) + self._lock = threading.Lock() + self._started_wall = time.time() + self._started_mono = time.monotonic() + self._listen: tuple[str, int] = ("", 0) + self._server_pk_hex: str | None = None + self._registries_loaded = 0 + self._active: dict[str, dict[str, Any]] = {} + self._history: deque[dict[str, Any]] = deque(maxlen=64) + + def set_listen(self, host: str, port: int) -> None: + with self._lock: + self._listen = (host, port) + + def set_boot_info(self, *, server_pk_hex: str, registries_loaded: int) -> None: + with self._lock: + self._server_pk_hex = server_pk_hex + self._registries_loaded = registries_loaded + + def open_connection(self, peer: str) -> str: + cid = uuid.uuid4().hex[:10] + rec: dict[str, Any] = { + "id": cid, + "peer": peer, + "opened_wall": time.time(), + "opened_mono": time.monotonic(), + "phase": "accepted", + "hello_op": None, + "transcript_hash_hex": None, + "auth_registry_hex": None, + "auth_role_hex": None, + "auth_ok": None, + "last_mgmt_cmd": None, + "mgmt_commands": 0, + "bytes_echoed": 0, + "error": None, + } + with self._lock: + self._active[cid] = rec + return cid + + def update_connection(self, cid: str, **fields: Any) -> None: + with self._lock: + rec = self._active.get(cid) + if rec is None: + return + rec.update(fields) + + def note_mgmt_command(self, cid: str, cmd: dict) -> None: + name = cmd.get("cmd") + with self._lock: + rec = self._active.get(cid) + if rec is None: + return + rec["last_mgmt_cmd"] = name + rec["mgmt_commands"] = int(rec.get("mgmt_commands") or 0) + 1 + + def note_echo_chunk(self, cid: str, n: int) -> None: + with self._lock: + rec = self._active.get(cid) + if rec is None: + return + rec["bytes_echoed"] = int(rec.get("bytes_echoed") or 0) + n + + def close_connection(self, cid: str, *, error: str | None = None) -> None: + with self._lock: + rec = self._active.pop(cid, None) + if rec is None: + return + rec = {**rec, "closed_wall": time.time(), "closed_mono": time.monotonic()} + if error: + rec["error"] = error + self._history.appendleft(rec) + + def snapshot(self) -> dict[str, Any]: + with self._lock: + uptime = time.monotonic() - self._started_mono + return { + "userid": self.userid, + "data_dir": self.data_dir, + "started_wall": self._started_wall, + "uptime_s": round(uptime, 3), + "listen": {"host": self._listen[0], "port": self._listen[1]}, + "server_public_key_hex": self._server_pk_hex, + "registries_loaded_boot": self._registries_loaded, + "active_connections": list(self._active.values()), + "recent_connections": list(self._history), + "active_connection_count": len(self._active), + } + + +def server_key_meta(data_dir: Path) -> dict[str, Any]: + """Non-secret metadata from ``server_key.json``.""" + path = data_dir / "server_key.json" + if not path.is_file(): + return {"present": False, "path": str(path)} + try: + data = json.loads(path.read_text()) + pub = base64.b64decode(data["public_b64"]) + sec = base64.b64decode(data["secret_b64"]) + return { + "present": True, + "path": str(path), + "public_key_hex": pub.hex(), + "secret_key_stored_bytes": len(sec), + } + except Exception as exc: + return {"present": True, "path": str(path), "error": str(exc)} + + +def collect_registry_debug(data_dir: Path) -> list[dict[str, Any]]: + """Per-registry rows from on-disk state (deserialized for version / ids).""" + reg_dir = data_dir / "registries" + if not reg_dir.is_dir(): + return [] + rows: list[dict[str, Any]] = [] + for p in sorted(reg_dir.glob("*.state")): + rid_file = p.stem + cert_path = reg_dir / f"{rid_file}.cert" + raw = p.read_bytes() + row: dict[str, Any] = { + "file_registry_id_hex": rid_file, + "state_path": str(p), + "state_bytes": len(raw), + "cert_path": str(cert_path) if cert_path.exists() else None, + "cert_bytes": cert_path.stat().st_size if cert_path.exists() else 0, + } + try: + st = zkac.RegistryState.deserialize(raw) + row["parsed_ok"] = True + row["version"] = st.version() + row["registry_id_hex"] = st.registry_id().hex() + row["state_hash_hex"] = st.state_hash().hex() + except Exception as exc: + row["parsed_ok"] = False + row["parse_error"] = str(exc) + rows.append(row) + return rows + + +def data_dir_tree(data_dir: Path, *, max_files: int = 200) -> list[dict[str, Any]]: + """Small file listing for transparency (names + sizes only).""" + out: list[dict[str, Any]] = [] + root = data_dir.resolve() + if not root.is_dir(): + return out + n = 0 + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + if n >= max_files: + out.append({"truncated": True, "note": f"listing capped at {max_files} files"}) + break + rel = path.relative_to(root) + try: + out.append({"path": str(rel).replace("\\", "/"), "bytes": path.stat().st_size}) + n += 1 + except OSError: + continue + return out diff --git a/cli/zkac_cli/store.py b/cli/zkac_cli/store.py index aaad9ee..17673fb 100644 --- a/cli/zkac_cli/store.py +++ b/cli/zkac_cli/store.py @@ -3,7 +3,10 @@ from __future__ import annotations import base64 +import hashlib import json +import os +import secrets from pathlib import Path import zkac @@ -23,33 +26,123 @@ def _ud(userid: str) -> Path: return user_dir(userid) +def _chmod_if_possible(path: Path, mode: int): + try: + os.chmod(path, mode) + except OSError: + pass + + +def _ensure_private_dir(path: Path): + path.mkdir(parents=True, exist_ok=True) + _chmod_if_possible(path, 0o700) + + +def _write_private_json(path: Path, payload: dict): + path.write_text(json.dumps(payload, indent=2)) + _chmod_if_possible(path, 0o600) + + # ── User identity ──────────────────────────────────────────────────── def create_user(userid: str) -> Path: d = ensure_user(userid) + _chmod_if_possible(d, 0o700) p = d / "identity.json" if p.exists(): raise FileExistsError(f"user {userid!r} already exists at {d}") issuance_kp = zkac.IssuanceKeypair() + transport_kp = zkac.Keypair() identity = { "issuance_secret_b64": _b64(issuance_kp.secret_bytes()), "issuance_public_b64": _b64(issuance_kp.public_key_bytes()), + "transport_secret_b64": _b64(transport_kp.secret_key_bytes()), + "transport_public_b64": _b64(transport_kp.public_key().to_bytes()), + "grant_token_b64": _b64(secrets.token_bytes(32)), } - p.write_text(json.dumps(identity, indent=2)) + _write_private_json(p, identity) for sub in ("admin", "credentials", "servers"): - (d / sub).mkdir(exist_ok=True) + _ensure_private_dir(d / sub) return d def load_identity(userid: str) -> dict: - data = json.loads((_ud(userid) / "identity.json").read_text()) + p = _ud(userid) / "identity.json" + data = json.loads(p.read_text()) return { "issuance_sk": _unb64(data["issuance_secret_b64"]), "issuance_pk": _unb64(data["issuance_public_b64"]), + "transport_sk": _unb64(data["transport_secret_b64"]), + "transport_pk": _unb64(data["transport_public_b64"]), + "grant_token": _unb64(data["grant_token_b64"]), } +def export_contact_bundle(userid: str, peer: str | None = None) -> str: + """One-string public contact bundle for out-of-band sharing.""" + ident = load_identity(userid) + payload = { + "v": 3, + "issuance_pk_hex": ident["issuance_pk"].hex(), + "transport_pk_hex": ident["transport_pk"].hex(), + "grant_token_b64": _b64(ident["grant_token"]), + } + if peer: + payload["peer"] = peer + raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + +def parse_contact_bundle(bundle: str) -> dict: + """Parse and validate a contact bundle into key hex fields.""" + try: + s = bundle.strip() + padding = "=" * (-len(s) % 4) + raw = base64.urlsafe_b64decode((s + padding).encode()) + data = json.loads(raw.decode("utf-8")) + except Exception as exc: + raise ValueError("invalid contact bundle encoding") from exc + + version = data.get("v") + if version != 3: + raise ValueError("unsupported contact bundle version (requires v3)") + issuance_hex = data.get("issuance_pk_hex", "") + transport_hex = data.get("transport_pk_hex", "") + if not isinstance(issuance_hex, str) or not isinstance(transport_hex, str): + raise ValueError("invalid contact bundle fields") + try: + issuance = bytes.fromhex(issuance_hex) + transport = bytes.fromhex(transport_hex) + except ValueError as exc: + raise ValueError("contact bundle keys must be hex") from exc + if len(issuance) != 32: + raise ValueError("issuance public key must be 32 bytes") + if len(transport) != 32: + raise ValueError("transport public key must be 32 bytes") + parsed = { + "issuance_pk_hex": issuance_hex, + "transport_pk_hex": transport_hex, + } + tok = data.get("grant_token_b64", "") + if not isinstance(tok, str): + raise ValueError("invalid contact bundle grant token field") + try: + token = _unb64(tok) + except Exception as exc: + raise ValueError("invalid contact bundle grant token encoding") from exc + if len(token) != 32: + raise ValueError("grant token must decode to 32 bytes") + parsed["grant_token_b64"] = tok + + peer = data.get("peer") + if peer is not None and not isinstance(peer, str): + raise ValueError("invalid contact bundle peer field") + if isinstance(peer, str) and peer.strip(): + parsed["peer"] = peer.strip() + return parsed + + def list_users() -> list[str]: home = zkac_home() if not home.exists(): @@ -63,22 +156,25 @@ def list_users() -> list[str]: # ── Server pins (per user) ─────────────────────────────────────────── def _server_key(server: str) -> str: - return server.replace(":", "_") + digest = hashlib.sha256(server.encode("utf-8")).hexdigest() + return f"sha256_{digest}" def pin_server(userid: str, server: str, server_pk_b64: str): d = _ud(userid) / "servers" - d.mkdir(parents=True, exist_ok=True) - (d / f"{_server_key(server)}.json").write_text( - json.dumps({"server_public_key_b64": server_pk_b64}, indent=2) + _ensure_private_dir(d) + _write_private_json( + d / f"{_server_key(server)}.json", + {"server": server, "server_public_key_b64": server_pk_b64}, ) def load_server_pin(userid: str, server: str) -> dict | None: - p = _ud(userid) / "servers" / f"{_server_key(server)}.json" - if not p.exists(): - return None - return json.loads(p.read_text()) + d = _ud(userid) / "servers" + new_path = d / f"{_server_key(server)}.json" + if new_path.exists(): + return json.loads(new_path.read_text()) + return None def known_servers(userid: str) -> list[str]: @@ -87,9 +183,13 @@ def known_servers(userid: str) -> list[str]: d = _ud(userid) / "servers" if d.exists(): for p in d.glob("*.json"): - host_port = p.stem.replace("_", ":") - if host_port not in seen: - seen.append(host_port) + try: + data = json.loads(p.read_text()) + except Exception: + continue + server = data.get("server") + if isinstance(server, str) and server and server not in seen: + seen.append(server) for rid in list_admin_registries(userid): try: reg = load_admin(userid, rid) @@ -134,8 +234,8 @@ def reconstruct_admin(data: dict) -> tuple: def save_admin(userid: str, registry_id_hex: str, info: dict): d = _ud(userid) / "admin" - d.mkdir(parents=True, exist_ok=True) - (d / f"{registry_id_hex}.json").write_text(json.dumps(info, indent=2)) + _ensure_private_dir(d) + _write_private_json(d / f"{registry_id_hex}.json", info) def load_admin(userid: str, registry_id_hex: str) -> dict: @@ -153,8 +253,8 @@ def list_admin_registries(userid: str) -> list[str]: def save_credential(userid: str, registry_id_hex: str, role_name: str, cred_data: dict): d = _ud(userid) / "credentials" - d.mkdir(parents=True, exist_ok=True) - (d / f"{registry_id_hex}_{role_name}.json").write_text(json.dumps(cred_data, indent=2)) + _ensure_private_dir(d) + _write_private_json(d / f"{registry_id_hex}_{role_name}.json", cred_data) def load_credential_data(userid: str, registry_id_hex: str, role_name: str) -> dict: diff --git a/cli/zkac_node.egg-info/PKG-INFO b/cli/zkac_node.egg-info/PKG-INFO index 4f43052..7bad0d3 100644 --- a/cli/zkac_node.egg-info/PKG-INFO +++ b/cli/zkac_node.egg-info/PKG-INFO @@ -1,5 +1,5 @@ Metadata-Version: 2.4 Name: zkac-node -Version: 0.2.1 +Version: 0.7.1 Requires-Python: >=3.10 Requires-Dist: zkac diff --git a/demo/zkac_admin_serve.py b/demo/zkac_admin_serve.py new file mode 100644 index 0000000..6a232a5 --- /dev/null +++ b/demo/zkac_admin_serve.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +""" +HTTP admin debug dashboard for ``zkac-node serve``. + +Runs the ZKAC TCP node in a background thread and serves a read-only web UI on +another port. Intended for loopback + I2P server-tunnel forwarding. + +**Security:** This page is fully transparent (registry metadata, live sessions, +public keys). Do not expose it to untrusted networks without tunnel ACLs. + +Usage:: + + uv sync --extra demo + uv run python demo/zkac_admin_serve.py alice \\ + --node-host 127.0.0.1 --node-port 9800 \\ + --web-host 127.0.0.1 --web-port 8766 +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import threading +import time +from pathlib import Path + +from flask import Flask, Response, jsonify, render_template_string, request + +from zkac_cli.paths import user_dir +from zkac_cli.server import serve +from zkac_cli.server_debug import ( + ServerDebugState, + collect_registry_debug, + data_dir_tree, + server_key_meta, +) + +_PAGE = """ + + + + + {% if refresh_s %} + + {% endif %} + ZKAC node — admin debug + + + +

ZKAC node — admin debug

+

+ User {{ snap.userid }} · TCP + {{ snap.listen.host }}:{{ snap.listen.port }} + · uptime {{ snap.uptime_s }}s + {% if refresh_s %} + · auto-refresh {{ refresh_s }}s + {% endif %} +

+ +
+
+

Status

+ + + + + + + +
Data directory{{ snap.data_dir }}
Started (wall){{ snap.started_wall }}
Server public key{{ snap.server_public_key_hex or "—" }}
Registries (boot){{ snap.registries_loaded_boot }}
Active TCP sessions + {{ snap.active_connection_count }} live +
Processpid={{ proc.pid }} threads={{ proc.threads }}
+
+ +
+

Server key file

+
{{ sk_meta | tojson(indent=2) }}
+
+ +
+

Registries (on disk + parsed)

+ {% if reg %} + + + + + + + + + {% for r in reg %} + + + + + + + + {% endfor %} +
ID (file)versionstate hashbytes (state / cert)parse
{{ r.file_registry_id_hex[:16] }}…{{ r.get("version", "—") }}{% if r.get("state_hash_hex") %}{{ r.state_hash_hex[:24] }}…{% else %}—{% endif %}{{ r.state_bytes }} / {{ r.cert_bytes }}{% if r.parsed_ok %}ok{% else %}fail{% endif %}
+ {% else %} +

No registry state files yet.

+ {% endif %} +
+ +
+

Session connections (live)

+ {% if snap.active_connections %} + + + + + + {% for c in snap.active_connections %} + + + + + + + + + + + + {% endfor %} +
idpeerphaseoptranscriptauth registryrolemgmt #echo bytes
{{ c.id }}{{ c.peer }}{{ c.phase }}{{ c.hello_op or "—" }}{% if c.transcript_hash_hex %}{{ c.transcript_hash_hex[:20] }}…{% else %}—{% endif %}{% if c.auth_registry_hex %}{{ c.auth_registry_hex[:16] }}…{% else %}—{% endif %}{% if c.auth_role_hex %}{{ c.auth_role_hex[:16] }}…{% else %}—{% endif %}{{ c.mgmt_commands or 0 }}{{ c.bytes_echoed or 0 }}
+ {% else %} +

No active connections.

+ {% endif %} +
+ +
+

Recent connections

+ {% if snap.recent_connections %} + + + + + {% for c in snap.recent_connections %} + + + + + + + + + {% endfor %} +
idpeerphasemgmt #echo byteserror
{{ c.id }}{{ c.peer }}{{ c.phase }}{{ c.mgmt_commands or 0 }}{{ c.bytes_echoed or 0 }}{{ c.error or "—" }}
+ {% else %} +

No recent disconnects recorded.

+ {% endif %} +
+ +
+

Data directory tree (debug)

+
{{ files | tojson(indent=2) }}
+
+ +
+

Full debug JSON

+

Machine-readable snapshot (same as /api/debug.json).

+
{{ full_json }}
+
+
+ + + + +""" + + +def _full_payload(debug: ServerDebugState, data_dir: Path) -> dict: + snap = debug.snapshot() + reg = collect_registry_debug(data_dir) + sk = server_key_meta(data_dir) + files = data_dir_tree(data_dir) + return { + "snapshot": snap, + "server_key_file": sk, + "registries": reg, + "data_dir_files": files, + "process": {"pid": os.getpid(), "threads": threading.active_count()}, + "generated_wall": time.time(), + } + + +def create_app(debug: ServerDebugState, data_dir: Path) -> Flask: + app = Flask(__name__) + + @app.get("/") + def index(): + refresh = request.args.get("refresh", "").strip() + refresh_s = int(refresh) if refresh.isdigit() and 1 <= int(refresh) <= 60 else None + data = _full_payload(debug, data_dir) + snap = data["snapshot"] + proc = data["process"] + full_json = json.dumps(data, indent=2, sort_keys=True) + return render_template_string( + _PAGE, + snap=snap, + reg=data["registries"], + sk_meta=data["server_key_file"], + files=data["data_dir_files"], + full_json=full_json, + proc=proc, + refresh_s=refresh_s, + ) + + @app.get("/api/debug.json") + def api_debug(): + return jsonify(_full_payload(debug, data_dir)) + + @app.get("/healthz") + def healthz(): + return Response("ok", mimetype="text/plain") + + return app + + +def main() -> None: + p = argparse.ArgumentParser(description="ZKAC node TCP + HTTP admin debug dashboard") + p.add_argument("userid", help="user whose ~/.zkac//server/ holds node state") + p.add_argument("--data-dir", default=None, help="override server data directory") + p.add_argument("--node-host", default="127.0.0.1") + p.add_argument("--node-port", type=int, default=9800) + p.add_argument("--web-host", default="127.0.0.1") + p.add_argument("--web-port", type=int, default=8766) + args = p.parse_args() + + data_dir = Path(args.data_dir) if args.data_dir else user_dir(args.userid) / "server" + data_dir = data_dir.resolve() + debug = ServerDebugState(userid=args.userid, data_dir=str(data_dir)) + + t = threading.Thread( + target=serve, + kwargs={ + "data_dir": str(data_dir), + "host": args.node_host, + "port": args.node_port, + "debug": debug, + }, + name="zkac-serve", + daemon=True, + ) + t.start() + time.sleep(0.15) + if not t.is_alive(): + print("ZKAC node thread died on startup; check stderr above.", file=sys.stderr) + sys.exit(1) + + app = create_app(debug, data_dir) + print(f"ZKAC TCP node: {args.node_host}:{args.node_port} (data {data_dir})") + print(f"Admin debug UI: http://{args.web_host}:{args.web_port}/") + print("Warning: admin UI exposes live sessions and registry metadata.", file=sys.stderr) + app.run(host=args.web_host, port=args.web_port, debug=False, threaded=True) + + +if __name__ == "__main__": + main() diff --git a/docs/FUZZING.md b/docs/FUZZING.md index 569d069..0a5e4c0 100644 --- a/docs/FUZZING.md +++ b/docs/FUZZING.md @@ -32,7 +32,7 @@ cargo fuzz build -s none handshake_respond cargo fuzz run -s none handshake_respond -- -max_total_time=60 ``` -The helper script sets `SANITIZER=none` by default for broad compatibility. It also prepends `~/.cargo/bin` to `PATH` so `cargo-fuzz` is found after `cargo install cargo-fuzz`. +The helper script sets `SANITIZER=none` by default for stable-toolchain usage. It also prepends `~/.cargo/bin` to `PATH` so `cargo-fuzz` is found after `cargo install cargo-fuzz`. ### Did anything crash? @@ -99,7 +99,9 @@ Install the `honggfuzz` binary (distro package or source). Point it at a binary ## CI smoke -GitHub Actions (`.github/workflows/fuzz-smoke.yml`) builds all targets with sanitizer `none`, then runs **`scripts/fuzz-libfuzzer.sh`** with **`FUZZ_RUNS=2000`** so every target gets a short fixed-iteration smoke (same idea as below). +GitHub Actions (`.github/workflows/fuzz-smoke.yml`) builds all targets with sanitizer `none`, then runs **`scripts/fuzz-libfuzzer.sh`** with **`FUZZ_RUNS=2000`** so every registered fuzz target gets a short fixed-iteration smoke (same idea as below). + +`scripts/fuzz-libfuzzer.sh` discovers targets dynamically from `cargo fuzz list`, so adding a new `[[bin]]` fuzz target in `fuzz/Cargo.toml` automatically includes it in local and CI smoke runs. Locally you can match that with: diff --git a/docs/PYTHON_API.md b/docs/PYTHON_API.md index d092c6e..a3044cb 100644 --- a/docs/PYTHON_API.md +++ b/docs/PYTHON_API.md @@ -1,6 +1,6 @@ # ZKAC Python API Reference -Version 0.5.1. Cryptographic stack: **BBS+** on BLS12-381 (credentials), **X25519** + **ChaCha20-Poly1305** (transport), **Schnorr/Ristretto255** (identity), **BLAKE2b** (role IDs, signatures), **LWE** (single-server SimplePIR for mailbox handles). +Version 0.7.1. Cryptographic stack: **BBS+** on BLS12-381 (credentials), **X25519** + **ChaCha20-Poly1305** (transport), **Schnorr/Ristretto255** (identity), **BLAKE2b** (role IDs, signatures). ```python import zkac @@ -12,33 +12,6 @@ import zkac Upper bound on BBS+ proof size in an encrypted auth packet (256 KiB). Larger proofs are rejected. -### `PIR_RECORD_BYTES` - -Fixed byte length of each PIR plaintext row (mailbox **handle** JSON, padded with zeros). **256** in 0.5.1. Must match the server’s PIR database packing and the `record_bytes` argument to `PirClient(...)`. - -## Single-server PIR (SimplePIR) - -Used by the CLI/WASM mailbox path: the server’s hint blob starts with magic ``ZKACSP1`` (not compatible with 0.4.x DoublePIR hints). - -### `PirDatabase(records, record_bytes)` - -`records` is a list of `bytes`, each **exactly** `record_bytes` long (padded plaintext cells, one byte per LWE plaintext slot). - -### `PirServer(db)` - -`hints() -> bytes`, `version() -> bytes` (32-byte pool/hint digest), `answer(query: bytes) -> bytes` (little-endian `u32` limbs: **one `u32` per record byte**, i.e. `record_bytes` limbs for SimplePIR). - -### `PirClient(hints_bytes, n_records, record_bytes)` - -`hints_bytes` must deserialize with matching `n_records` and `record_bytes`. - -- `query(index) -> (query_bytes, PirClientState)` — `query_bytes` length **4 × n_records** (one `u32` per database column). -- `decode(answer_bytes, state) -> bytes` — `answer_bytes` must hold **`record_bytes` × 4** bytes (`record_bytes` little-endian `u32`s). Consumes `PirClientState`. - -### `grant_detection_tag(secret_key: bytes, public_key: bytes) -> bytes` - -16-byte tag for grant discovery (`secret_key` / `public_key` are 32-byte X25519 keys). - ## Transport identity (Ristretto255) ### `Keypair()` diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 1caf956..0dba3b3 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,273 +1,71 @@ -# Security model and audit notes (ZKAC 0.5.1) +# Security model (ZKAC 0.7.1) -This document summarizes the design, residual risks, and recommendations for operators integrating **ZKAC**. It is not a substitute for independent review before high-assurance deployment. +This document summarizes the direct peer-to-peer grant model, with transcript-bound BBS+ authorization (Option C). ## Goals -- **Authentication:** Only holders of a valid BBS+ credential for a registered role can complete `verify_auth` for that role. -- **Server identity:** The server proves its long-term identity to the client via a Schnorr signature over the session transcript; clients verify against a pinned public key. This prevents MITM attacks without requiring TLS. -- **Confidentiality & integrity:** All traffic (management and authenticated sessions) is authenticated-encrypted (ChaCha20-Poly1305) with keys derived from an ephemeral X25519 handshake. -- **Replay resistance:** Duplicate ciphertexts in a direction are rejected (sliding window + monotonic counter). -- **Unlinkability (credential layer):** BBS+ presentations are unlinkable across sessions when the presentation header (the session transcript hash) differs; the verifier learns only the disclosed attributes (opaque `role_id`, epoch) and validity. Client anonymity is preserved: the client never reveals its long-term key during the handshake. -- **Server cannot forge credentials:** The server stores only the issuer **public** key per role; forging requires the issuer secret key. -- **Opaque server:** The server stores only cryptographically verified state blobs and opaque grant ciphertexts. No user identities, role names, or credential material are stored or visible to the server. +- MITM-safe transport using transcript-bound authentication. +- Anonymous-authorized grant sender: recipient verifies sender is a valid admin for the target registry, without requiring a stable sender identifier. +- End-to-end credential confidentiality from sender to recipient. +- Replay and downgrade resistance on the encrypted transport channel. ## Cryptographic components -| Layer | Primitive | Purpose | -|-------|-----------|---------| -| Transport | X25519 ephemeral DH, HKDF-SHA256, ChaCha20-Poly1305 | Session keys, AEAD | -| Identity | Schnorr on Ristretto255, BLAKE2b-512 challenge | Server identity binding | -| Credentials | BBS+ on BLS12-381 (zkryptium), SHAKE256 ciphersuite | Blind issuance, ZK presentations | -| Role IDs | BLAKE2b-512 (truncated to 32 bytes) | Opaque role identifiers | -| Grant delivery | X25519 static/ephemeral DH, HKDF-SHA256, ChaCha20-Poly1305 | E2E-encrypted credential grants | -| Grant discovery | X25519 DH + BLAKE2b-512 truncated to 16 bytes | Detection tags for anonymous matching | -| PIR | LWE (n=1024, q=2^32, p=256, σ=6.4) | Single-server private record retrieval | - -## Protocol flow - -### Unified channel (all connections) - -``` -Client Server - |--- init_msg (eph_pk) ------------>| - | | accept() - | | prove_identity() → sign(transcript) - |<-- response_msg + identity_pkt ---| - | complete DH | - | decrypt + verify server sig | - |===== encrypted session ==========>| - |--- {op: "mgmt"} or {op: "auth"}->| -``` - -Management commands (`create_registry`, `post_grant`, etc.) and BBS+ role authentication both run inside the same encrypted, server-authenticated channel. There is no unencrypted management path. - -### Grant delivery (admin → recipient, through server) - -Grants live in a single **anonymous append-only pool** (no recipient identifier on the server). Each grant entry carries an ephemeral public key, the E2E-encrypted credential payload, and a 16-byte **detection tag**. - -**Discovery (cheap, no PIR):** The server exposes a `pool_tags` command returning all `(eph_pk, tag)` pairs. The client computes `X25519(my_issuance_sk, eph_pk_j)` for each entry and derives the expected tag via `BLAKE2b-512("zkac-grant-tag" || shared_secret)[..16]`. Matching entries are the client's grants. This scan is a single round-trip transferring ~48 bytes per pool entry and is computed locally. - -**Retrieval (single-hop PIR row):** For each matching pool index, the client runs LWE-based single-server **SimplePIR** (`pir_query`). The PIR database row contains the full encrypted mailbox row (ephemeral public key, detection tag, ciphertext, ciphertext digest) padded to `PIR_RECORD_BYTES`. The client validates the row digest and decrypts locally. There is no `grant_id` second hop, so retrieval does not reveal a stable row identifier beyond the PIR exchange itself. Hints use `H = D · A^T` (seeded public matrix `A`); the client caches hints keyed by `pool_version`. - -``` -Admin Server (opaque relay) Recipient - |-- post_grant ------->| | - | (admin_proof, | appends to pool: | - | eph_pk, | {eph_pk, ciphertext, | - | ciphertext, | to_tag, claimed=false} | - | to_tag) | (no recipient address) | - | | | - | |<-- pool_tags --------------| - | |--- [(eph_pk, tag), …] ---->| - | | | local tag match - | |<-- pir_query(j) -----------| - | |--- answer ----------------->| - | | | PIR decode full row - | | | verify digest → decrypt -``` - -## PIR security (LWE) - -Private information retrieval uses the **SimplePIR** construction (Henzinger–Hong–Corrigan-Gibbs–Meiklejohn–Vaikuntanathan, USENIX Security '23). Security rests on the **decisional Learning With Errors (LWE)** assumption: - -- **Parameters:** LWE dimension n=1024, ciphertext modulus q=2^32, plaintext modulus p=256, discrete Gaussian noise σ=6.4. -- **Classical security:** ~128 bits (based on lattice estimator analysis at these parameters). -- **Post-quantum:** LWE is believed hard for quantum computers; no known quantum algorithm breaks it in polynomial time. -- **Single-server:** No non-collusion assumption. Privacy holds against an honest-but-curious server that inspects all queries and answers. - -The PIR scheme is **honest-but-curious only**: a malicious server can return incorrect answers. This is acceptable because grant payloads are E2E-encrypted (ChaCha20-Poly1305) and credential finalization validates BBS+ blind signatures — a corrupted PIR answer causes decryption or BBS+ verification to fail, not credential forgery. - -## Detection tags - -Each grant carries a 16-byte detection tag: `BLAKE2b-512("zkac-grant-tag" || X25519(eph_sk, recipient_pk))[..16]`. - -**Privacy properties:** -- The tag is a deterministic function of the shared secret, which requires knowledge of either the ephemeral secret key or the recipient's issuance secret key to compute. An observer (including the server) who knows neither key cannot link a tag to a recipient. -- The `pool_tags` list is equivalent to what the server already sees at grant insertion time — broadcasting it to querying clients reveals no new information. -- A client downloading `pool_tags` reveals that it is checking for pending grants, but not which entries matched. Matching is a local computation. -- Tags have 128-bit collision resistance (16 bytes); false positives are negligible. - -## Scaling and complexity (transport, credentials, registries) - -This section complements the **grant pool / PIR** analysis above. Asymptotics use: **R** = number of roles in one registry state, **G** = number of registries hosted in memory, **L** = byte length of an application payload (JSON management command or auth packet body after decryption). - -### Transport and session crypto - -| Operation | Time | Bandwidth / memory | -|-----------|------|----------------------| -| Handshake (`connect` / `accept`) | **O(1)** | Fixed 32-byte handshake messages; one X25519 DH, HKDF, ChaCha open. | -| Server identity proof | **O(1)** | Schnorr verify on Ristretto255 over a short transcript-derived message. | -| `Session::encrypt` / `decrypt` per frame | **O(L)** | ChaCha20-Poly1305 is linear in payload size; replay window checks are **O(1)** per direction. | - -**Bottlenecks:** negligible compared to BBS+ unless payloads are pushed toward frame limits. Python framing caps TCP payloads at `MAX_BBS_AUTH_PROOF_BYTES + 4 KiB` (~260 KiB), bounding worst-case allocations per read. - -### BBS+ credentials (issuance and verification) - -| Operation | Time | Notes | -|-----------|------|-------| -| Blind `issue_blind` / `finalize` (issuer / member) | **O(1)** in R and G | Dominated by BLS12-381 and BBS+ proof math in zkryptium (pairings, multi-scalar muls); not sensitive to registry count or pool size. | -| `present` (proof generation) | **O(1)** | Produces a presentation bound to a nonce (e.g. transcript hash). | -| `verify_presentation` | **O(1)** | One proof check against one issuer public key. | -| Proof size on the wire | **≤ 256 KiB** | `MAX_BBS_AUTH_PROOF_BYTES`; caps attacker-controlled allocation for auth packets. | - -**Bottlenecks:** **BBS+ verify and present** dominate CPU on authenticated paths (role auth, admin proofs for `post_grant`, registry state certification). Cost is **per event**, not per grant in pool, but high QPS auth still needs horizontal scaling or hardware tuned for pairing-heavy crypto. - -### Registry state (client-managed blob on server) - -| Operation | Time | Size | -|-----------|------|------| -| `RegistryState::serialize` / `deserialize` | **O(R)** | Linear in number of role entries (each: fixed `role_id`, variable-length issuer pk bytes, epoch). | -| `state_hash` | **O(|state_bytes|)** ≈ **O(R)** | One BLAKE2b-512 over the serialized state. | -| `certify` / `verify_cert` | Same as BBS+ present / verify | One presentation over `state_hash`. | -| `RegistryManager::update` | **O(R)** for cache rebuild | Deserializes old + new state, verifies cert and version chain, rebuilds `RoleRegistry` cache by iterating all roles (`build_role_cache`). | - -**Bandwidth:** `get_registry` / `create_registry` / `update_registry` move the **full serialized state** and **state certificate** each time — **O(R)** bytes per round-trip. Very large role lists mean large management frames and more CPU on every update. - -**Bottlenecks:** **Large R** (many roles in one registry) inflates state blob size, hash work, and cache rebuild. **Frequent updates** multiply BBS+ certify/verify cost. - -### RegistryManager (multi-registry server) - -| Operation | Time | Notes | -|-----------|------|-------| -| `create` / `get` / `update` / `verify_*` | **O(1)** expected in G | Hash map on `registry_id`; work is on **one** stored registry at a time. | -| In-memory footprint | **O(G × (|state| + |cert| + queues))** | Each registry holds state bytes, cert bytes, `RoleRegistry` cache, and issuance **queues** (below). | - -**Bottlenecks:** **G** grows with every distinct registry the server accepts — mostly a **RAM** and operational concern. Per-request CPU is still dominated by BBS+ and (for managed flows) issuance queue handling. - -### Issuance request queues (`RegistryManager`) - -| Structure | Growth | Risk | -|-----------|--------|------| -| `pending_requests` / `granted` maps | **Unbounded** per registry unless the application drains them | A client could queue many `queue_issuance_request` entries; server memory grows with pending items. Not the same as the grant pool file, but a similar **resource exhaustion** class. | - -**Bottlenecks:** **Queue depth** per registry; mitigations are rate limits, caps, or TTL policies at the application layer (not enforced in core today). - -### Issuance encryption (X25519 + ChaCha) - -| Operation | Time | -|-----------|------| -| `encrypt` / `decrypt` (grant payloads, admin replies) | **O(L)** for payload length L | - -Negligible vs BBS+ for typical small JSON blobs. - -### Summary: dominant costs outside the grant pool - -1. **BBS+ present/verify** on every auth, admin proof, and registry certificate path — **pairing-heavy**, fixed per operation, proof capped at 256 KiB. -2. **Registry state size and `update`** — **O(R)** serialization, hashing, and full cache rebuild. -3. **Issuance queues** — **unbounded** pending entries per registry if abused. -4. **Transport** — **O(L)** per frame; handshake **O(1)**. - -The **grant pool** remains the subsystem whose **per-operation** cost scales with **pool length n** (discovery, PIR query, PIR answer compute); the rest of the protocol scales mainly with **roles per registry**, **registry count**, and **proof operations per session**, not with anonymous pool size. - -## Threats considered - -### Network attacker (passive) - -- Observes ciphertexts; cannot break ChaCha20-Poly1305 or derive session keys without breaking X25519 / HKDF under standard assumptions. -- Management traffic is indistinguishable from auth traffic at the wire level (same handshake, same framing). - -### Network attacker (active / MITM) - -- **Server impersonation:** The server signs the session transcript hash with its long-term Ristretto255 key (`prove_identity`). The client verifies this signature against the **pinned** server public key. A MITM running a separate DH exchange produces a different transcript; it cannot forge the server's signature. The client aborts on mismatch. -- **Client impersonation:** The BBS+ presentation is bound to the session transcript hash. A MITM cannot relay a presentation from one session to another (different transcripts) or forge one (requires a valid credential from the issuer). -- **Relay attack:** A MITM that relays the real server's identity proof to a client fails because the proof is encrypted under the MITM-to-server session keys (not the client-to-MITM keys), and the signature is over the wrong transcript. -- **Management channel:** All management commands (registry creation, grants) are protected by the same encrypted channel, eliminating the previous plaintext management path. - -### Malicious server - -- Can **learn** opaque `role_id`, current epoch, and that *some* valid member authenticated. -- Sees `registry_id` values (needed for routing) but not role names or registry contents beyond opaque state bytes. -- Sees `eph_pk`, `to_tag`, and ciphertext per grant in the anonymous pool, and pool size / timing of syncs, but cannot decrypt grant payloads or link tags to recipients. -- Sees PIR queries, which are LWE-encrypted under the decisional LWE assumption — cannot determine which pool index the client is retrieving (single-server, no collusion needed). -- **Cannot** forge BBS+ credentials without the issuer secret key. -- **Cannot** learn `member_secret` from presentations under the BBS+ security assumptions. -- **Cannot** distinguish which specific member authenticated among valid credential holders (unlinkability holds against the verifier for distinct presentation headers). -- **Cannot** learn the client's long-term public key — it is never transmitted during handshake or auth. -- **Cannot** perform admin operations (registry updates, grant posting) without a valid admin BBS+ credential. -- **Cannot** correlate a recipient's mailbox identity with their authenticated sessions (different keys, unlinkable proofs). -- **Can** censor grants by omitting tags from `pool_tags` or returning corrupted PIR answers. Corrupted answers are caught by E2E decryption / BBS+ verification failures. Censorship is a residual operational risk; cross-checking pool hashes across replicas mitigates it. - -### Malicious client - -- Cannot decrypt others' traffic without session keys. -- Cannot produce valid auth for a role without a valid credential + correct epoch + registry entry. - -### Denial of service - -- **Auth packet size:** Proof length is capped (`MAX_BBS_AUTH_PROOF_BYTES`, 256 KiB) to bound allocations. -- **Handshake:** Fixed 32-byte messages; no variable-length handshake parsing. -- **Grant pool growth:** The anonymous pool is append-only with tombstoned rows (`claimed`), so **pool length `n` never shrinks** on disk. A malicious or careless admin can grow `n` without bound: larger `pool_tags` downloads, longer PIR hint **recomputation** when the pool version bumps, and **per-query** PIR cost linear in `n` (see Known limitations). This is a **storage and workload amplification** vector, not credential forgery. Mitigation belongs in future work (pool caps, compaction, generations). -- General packet limits should still be enforced at the application layer (total message size, rate limits). - -## Key distribution - -The server's long-term `PublicKey` (32-byte Ristretto255 point) functions as a **self-authenticating identity** — no certificate authority is required. The client must obtain and pin this key before connecting. - -Recommended strategies: - -1. **Static configuration** (default): embed the server public key in client config or CLI pin command (`zkac-node server pin --key `). Equivalent to WireGuard's `[Peer] PublicKey = ...`. -2. **Trust On First Use (TOFU):** accept the server's key on first connection, pin it for subsequent sessions. Risk: first connection is vulnerable. -3. **Out-of-band verification:** compare public key fingerprints over a trusted side channel (phone, in-person, encrypted messaging). -4. **Key registry / directory:** a trusted service maps names to public keys. Shifts trust to the registry and its authentication channel. - -## Operational requirements - -1. **Issuer secret key:** Protect `BbsIssuer` secret material (HSM, KMS, or encrypted at rest). Compromise = ability to issue arbitrary credentials for that role. -2. **Server long-term key:** Protect the server's `server_key.json`. Compromise = ability to impersonate the server. Rotate the key and distribute the new public key to clients if compromised. -3. **Member storage:** `member_secret` and finalized `Credential` material must be protected; loss = re-enrollment required. -4. **Epoch revocation:** On compromise or policy change, call `set_epoch` and re-issue credentials only to legitimate members; old credentials become invalid at verification time. -5. **Registry integrity:** Registry state is integrity-protected by BBS+ state certificates (admin must sign updates). The server verifies these certificates before accepting changes. -6. **Role ID privacy:** `role_id` is a hash of the role name only if you use `role_id("myrole")`; treat role names as secrets if enumeration is a concern, or derive role IDs with an additional secret salt known to members. -7. **Recipient addressing:** Admins encrypt grants to the recipient's issuance public key off-server; that key is not used as a server-side mailbox index. Recipients are identified to the issuer out-of-band only. - -## Implementation notes (audit checklist) - -- [x] BBS+ proof verification uses the same header and presentation binding as proof generation (`verify_presentation` in Rust). -- [x] Session transcript is included in the presentation via `present(transcript_hash)`. -- [x] Server identity proof: Schnorr signature over `transcript_hash`, verified against pinned public key before any traffic. -- [x] Schnorr nonce is deterministic (`H(sk || msg)`) — no dependence on RNG quality at signing time. -- [x] Replay protection is symmetric per direction in `Session`. -- [x] Constant-time comparisons are used where critical in transport/replay paths (`subtle` crate). -- [x] Client long-term key is never transmitted, preserving BBS+ unlinkability. -- [x] Management and auth channels use the same encrypted handshake (no plaintext management path). -- [x] Admin proofs in `post_grant` are bound to the session transcript hash (no separate nonce); the CLI uses **one TCP session per grant** so each proof uses a fresh transcript. -- [x] After collect, the client persists the server public key from `server_info` (never a placeholder key). -- [x] Server stores only opaque state bytes, state certs, and encrypted grant blobs (no role names, no user IDs). -- [x] PIR queries are LWE-encrypted; the server cannot determine the queried index. -- [x] Detection tags are derived from X25519 shared secrets and cannot be linked to recipients by the server. -- [ ] **External:** Python bindings surface raw bytes; callers must not log secrets (`secret_key_bytes`, `member_secret`, `prover_blind`). -- [ ] **External:** Use secure randomness from the OS (library uses OS RNG for key generation paths exposed in Rust). - -## Design decisions - -- **Unified encrypted channel:** All traffic (management and auth) uses the same anonymous handshake. This eliminates the attack surface of an unencrypted management path and simplifies the protocol to a single mode. -- **Anonymous handshake (`complete_connect_anon`):** The client verifies the server's identity but does not authenticate itself during the handshake. BBS+ auth is sent as an application-layer message inside the encrypted session, not as part of the handshake. This allows the same channel for both anonymous management and authenticated role access. -- **Server-only identity proof:** Only the server signs the transcript. Adding client long-term signing would break BBS+ unlinkability (the server could correlate sessions by client public key). Client authentication is handled entirely by the anonymous BBS+ credential. -- **Deterministic Schnorr nonces:** The signing nonce is derived as `H("zkac-schnorr-nonce" || sk || msg)`, eliminating a class of RNG-failure attacks (cf. PS3 ECDSA, Sony 2010). Same key + same message = same signature. -- **Anonymous grant pool:** Grant entries contain `(eph_pk, ciphertext, to_tag)` plus row metadata — no registry ID or role name. Recipients discover their grants via detection tags and retrieve full encrypted rows via LWE PIR in one step. -- **No user IDs on server:** The server has no concept of user accounts. It is a stateless relay authenticated only by cryptographic proofs. -- **Single-server PIR (LWE):** Eliminates the two-server non-collusion assumption of the previous XOR PIR design. Query privacy rests on decisional LWE, not operational trust in multiple server operators. -- **Detection tags for discovery:** A 16-byte tag derived from X25519 DH allows O(n) local matching from a cheap bulk download, reducing PIR usage from O(n) queries to O(matches) queries per scan. -- **One session per admin grant (CLI):** Each `post_grant` runs in its own connection so `verify_admin` nonces are not reused across grants in a single session. - -## Known limitations - -- **Epoch granularity:** Revocation is coarse (epoch bump); plan issuance and rotation policy accordingly. -- **zkryptium dependency:** Security follows the underlying crate and BLS12-381/BBS+ standards; keep dependencies updated. -- **Key distribution:** The library provides the cryptographic mechanism; initial key distribution is an application-layer responsibility. -- **Honest-but-curious PIR:** The server can return incorrect PIR answers. Corrupted answers are caught by E2E decryption / BBS+ verification, but censorship (omitting grants) is not detected at the PIR layer. Cross-replica hash comparison or a transparency log can mitigate this. -- **Hint size:** PIR hints are approximately `56 + record_bytes × N_LWE × 4` bytes (on the order of **8 MiB** with `record_bytes = 2048` and `N_LWE = 1024`). Hints are cached client-side and only refetched when the pool version changes. -- **Unbounded grant pool:** Rows are append-only and currently not privately claimed/deleted in protocol. Pool length `n` therefore grows monotonically with every posted grant. That increases discovery traffic (`pool_tags` is O(n)), PIR query size (O(n) bytes per query), server work per PIR answer (O(n × record_bytes)), and hint **rebuild** cost when the pool changes (O(n × record_bytes × N_LWE)). Operators should plan for bounded pools or archival; the codebase does not yet enforce limits. - -## Future work - -- **Bounded grant pool and anti-DoS:** Introduce explicit **pool caps**, **rate limits** on `post_grant`, **per-registry quotas**, or **pool generations** (rotate to a fresh empty pool while archiving the old one). Optionally **compact** the on-disk pool by rewriting only unclaimed rows and bumping a generation id so PIR indices stay meaningful without retaining every tombstone forever. Any design must preserve stable addressing for in-flight collects or migrate clients with explicit pool ids. -- **Scale beyond large `n`:** Today’s bottleneck is **linear cost in pool length `n`** for each PIR retrieval: client upload ~4n bytes per query, server matrix–vector multiply O(n × record_bytes), and discovery O(n). For very large pools, future work includes **sublinear-communication PIR** (e.g. DoublePIR-style layering), **sharded pools** with client-side routing, **streaming or chunked hints**, or **moving heavy work off the hot path** (precomputed answers, CDN for hints) — trading complexity, trust, or privacy for throughput. -- **DoublePIR / layered PIR:** Production mailbox PIR remains SimplePIR; layered/sublinear PIR remains future work. -- **Verifiable PIR:** Adding a commitment to the pool state (e.g. Merkle tree or KZG) and proof of correct answer computation would defend against malicious server responses beyond what E2E encryption catches. -- **Pool commitment / transparency:** Publishing a hash of `(pool_version, hints, tags)` to a public log or allowing cross-replica comparison would detect censorship by a malicious server. - -## Reporting issues - -Report security-sensitive findings through your project's private disclosure channel (configure `SECURITY.md` contact or GitHub security advisories when the repository is public). +- Transport: X25519 ephemeral DH, HKDF-SHA256, ChaCha20-Poly1305. +- Identity proofing: Schnorr signatures on Ristretto255 over session transcript. +- Authorization proofs: BBS+ presentations over BLS12-381. +- Registry integrity: certified `RegistryState` with BBS+ admin proof checks. +- Grant payload encryption: X25519 + HKDF-SHA256 + ChaCha20-Poly1305 issuance envelope. + - The issuance envelope format is `nonce || ciphertext` where `nonce` is a random 96-bit value generated per message. + - KDF context is direction-separated (`user->admin` vs `admin->user`) to avoid cross-direction key reuse. + +## Direct p2p grant flow + +1. Sender and recipient establish an encrypted peer session. +2. Sender prepares a blind-issued credential payload for recipient and encrypts it with recipient issuance key material. +3. Sender transmits: + - encrypted payload, + - certified registry state snapshot, + - transcript-bound admin BBS+ proof. +4. Recipient verifies: + - registry state certificate, + - restored registry ID matches the announced outer `registry_id`, + - admin proof bound to current session transcript. +5. Recipient decrypts payload and enforces binding checks: + - payload `registry_id` equals authenticated outer `registry_id`, + - payload role matches announced role metadata, + - reconstructed credential verifies against the certified registry state (`verify_presentation`) before storage. +6. Recipient stores credential only after all checks pass. + +## Threat model + +- Passive network attacker: + - Cannot recover plaintext or keys without breaking X25519/ChaCha20-Poly1305 assumptions. +- Active MITM: + - Cannot relay proofs across sessions because authorization proof nonce is session transcript hash. + - Cannot impersonate peer without required long-term key/proof material. +- Malicious sender without admin credential: + - Rejected during transcript-bound BBS+ admin verification. +- Malicious recipient: + - Can only decrypt payloads addressed to its issuance key. +- Replay: + - Rejected by session replay protections (monotonic counter + replay window). + +## Security properties and limits + +- `anonymous_authorized` mode: + - Recipient learns sender is authorized for the registry, not a mandatory stable real-world identity. + - Network metadata (IP/endpoint timing) is still visible to direct peers. +- Grant-context binding: + - Recipient rejects grants where outer authenticated context and inner decrypted payload disagree. + - Recipient rejects credential material that does not verify against the supplied certified registry state. +- Registry freshness: + - Recipient verifies cryptographic validity of received state; deployment policy should define freshness expectations. +- Key management remains operationally critical: + - Protect admin/BBS+ secret material and transport keys. + +## Operational guidance + +- Pin or verify peer transport keys out-of-band before first direct grant exchange. +- Rotate compromised keys and re-issue credentials with epoch/state updates. +- Do not log credential secrets, issuance secret keys, or raw proof material. diff --git a/docs/WHITEPAPER.md b/docs/WHITEPAPER.md new file mode 100644 index 0000000..57898c4 --- /dev/null +++ b/docs/WHITEPAPER.md @@ -0,0 +1,214 @@ +# Trustless Federated Authorization + +## Abstract + +Internet services traditionally rely on trusted servers for authentication, authorization, and routing. Blockchains reduce server trust through global consensus, but impose substantial latency, replication, and operational cost. This whitepaper presents a third model: **trustless federation**. In this model, nodes still host registries and provide service endpoints, yet cryptographic protocol design limits what a malicious node can learn or forge. Authorization is proven with transcript-bound zero-knowledge credentials, credential transfer is end-to-end encrypted, and policy state is verifiable by clients. The result is a practical system positioned between classical TCP/IP client-server trust and blockchain-style distributed consensus. + +## 1. Idea and Motivation + +### 1.1 The gap between two worlds + +Modern networking stacks solve transport and connectivity well, but application trust usually collapses to "trust the server operator." At the other extreme, blockchains attempt to remove this assumption by making consensus globally replicated and tamper-resistant. + +Both models work, but both have trade-offs: + +- **Client-server model:** efficient, low latency, but high operator trust. +- **Blockchain model:** low operator trust, but expensive consensus and reduced throughput. + +The central idea of this protocol family is to provide an **in-between layer**: + +- Federated nodes exist (for hosting registries and serving traffic). +- Those nodes are treated as potentially malicious. +- Cryptography prevents nodes from learning protected credential data or forging authorization. + +### 1.2 Privacy-first motivation + +For privacy-sensitive services, users want to prove permissions without exposing stable identity. If this protocol runs over anonymity overlays such as Tor or I2P, network-level identifiers become less meaningful, and the protocol can support anonymous service access with minimized trust in servers. + +## 2. Design Goals + +The system targets the following properties: + +- **Trustless authorization:** a node should not be able to forge client permissions. +- **MITM resistance:** authentication material must be bound to the actual session. +- **Anonymous authorization:** verify "is authorized" without requiring real-world identity. +- **End-to-end credential confidentiality:** transfer should be unreadable to intermediaries. +- **Federated practicality:** avoid heavy global consensus for each interaction. +- **Composability with existing Internet infrastructure:** deploy over TCP today, anonymity overlays tomorrow. + +## 3. Positioning Against Existing Protocol Families + +### 3.1 Versus traditional Internet service protocols + +In common web architectures, TLS protects transport, but servers still decide and observe most authentication/authorization details. This protocol reduces that trust requirement by moving proof validity into cryptographic verification that endpoints can perform independently. + +### 3.2 Versus blockchain systems + +Blockchains provide strong tamper resistance via global consensus and replication. This protocol intentionally does not replicate all state globally. It keeps consensus/governance lightweight and local to federated registry hosting, while ensuring malicious operators cannot violate core authorization integrity. + +### 3.3 Intended operating point + +This protocol is best viewed as: + +- More trust-minimizing than standard centralized services. +- Lighter and more responsive than blockchain consensus systems. +- Suitable for private, federated service networks. + +## 4. System Model + +### 4.1 Roles + +- **Federated nodes:** host registries and provide service endpoints. +- **Issuers/Admins:** create and authorize credential grants. +- **Clients:** receive credentials and authenticate to services. +- **Recipients/Peers:** participate in direct credential exchange. + +### 4.2 Assumptions + +- Nodes may be malicious, curious, censoring, or unreliable. +- Endpoints protect secret keys. +- Cryptographic primitives are assumed sound. +- Availability is not guaranteed by cryptography alone. + +## 5. System Architecture + +### 5.1 High-level architecture + +The architecture separates concerns: + +- **Registry layer:** federated nodes host verifiable authorization state. +- **Session layer:** peers establish encrypted channels with transcript material. +- **Authorization layer:** BBS+ presentations prove role/authority. +- **Credential transport layer:** credential payloads are transferred directly peer-to-peer under end-to-end encryption. + +### 5.2 Direct credential grant flow + +1. Sender and recipient establish a secure session. +2. Sender generates a transcript-bound BBS+ authorization proof. +3. Sender sends encrypted credential payload plus registry-state evidence. +4. Recipient verifies the certified registry state and validates sender admin proof against the live session transcript. +5. Recipient decrypts payload and enforces context binding checks (outer announced registry/role context must match inner decrypted fields). +6. Recipient verifies reconstructed credential material against certified registry role keys before local storage. + +This design intentionally favors direct peer exchange over private mailbox retrieval constructions. In practice, PIR/ORAM-style mailbox systems can provide stronger access-pattern privacy, but they introduce high implementation complexity, high constant-factor costs, larger hint/material transfers, and operationally difficult performance tuning. For interactive service environments, direct end-to-end grant exchange offers a better complexity/performance trade-off while preserving the core trustless authorization properties. + +## 6. Cryptographic Backbone + +### 6.1 Session and channel security + +- **Key exchange:** ephemeral X25519 Diffie-Hellman for forward-secret session establishment. +- **Key schedule:** HKDF-SHA256 domain-separated derivation of directional send/receive keys and transcript hash material. +- **Record protection:** ChaCha20-Poly1305 AEAD with associated data bound to message counters. +- **Replay handling:** monotonic counters plus replay-window checks for per-direction packet acceptance. +- **Identity proofing:** Schnorr signatures over transcript-derived context to authenticate peer/server identity keys when required by deployment policy. + +### 6.2 Transcript binding + +A transcript hash from the handshake/session is used as proof challenge material. This binds authorization proofs to the exact session and prevents replay across different channels. + +### 6.3 Authorization proofs + +BBS+ presentations provide zero-knowledge role/authority proofs with selective disclosure properties. Recipients verify proof validity against issuer/registry material without learning hidden witness data. + +Operationally, proof verification is done against certified registry state (issuer keys, role identifiers, epoch/version constraints). This allows a verifier to accept an authorization claim without trusting the transport node's interpretation of policy. + +### 6.4 Credential confidentiality + +Credential payloads are encrypted end-to-end to recipient key material. Intermediate nodes may relay or host state, but cannot decrypt protected credential contents. + +The credential issuance envelope uses ECDH-derived symmetric keys with AEAD integrity protection, so tampering causes decryption/validation failure rather than silent corruption. The concrete envelope is `nonce || ciphertext`, where `nonce` is a random 96-bit value per message. HKDF context is direction-separated for `user->admin` and `admin->user` traffic so both directions do not share one AEAD key domain. + +### 6.5 Primitive suite (current profile) + +- X25519 for ECDH key agreement. +- HKDF-SHA256 for key derivation and transcript-bound expansion. +- ChaCha20-Poly1305 for authenticated encryption. +- BBS+ (BLS12-381 ciphersuite) for anonymous authorization presentations. +- Schnorr signatures on Ristretto255 for transport identity proofs. +- BLAKE2-family hashing for role/identifier derivation and protocol-domain separation. + +## 7. Security and Privacy Properties + +### 7.1 What malicious nodes should not be able to do + +- Forge valid authorization proofs. +- Decrypt end-to-end encrypted credential payloads. +- Rebind valid proofs to unrelated sessions. +- Extract hidden secrets from zero-knowledge presentations. + +### 7.2 Residual risks and limits + +- **Censorship/DoS:** nodes can refuse service or delay traffic. +- **Metadata leakage:** timing and routing metadata may persist unless anonymized transports are used. +- **Operational key hygiene:** endpoint compromise remains critical. + +## 8. Threat Model and Attacker Games + +This section states explicit adversarial games suitable for academic evaluation. Let `A` denote a PPT adversary controlling network scheduling and any subset of federated nodes unless otherwise stated. + +### 8.1 Adversary classes + +- **Network adversary:** full active MITM capabilities (intercept, modify, replay, inject). +- **Malicious federation-node adversary:** controls registry-hosting/service nodes. +- **Endpoint compromise adversary:** compromises sender or recipient endpoint keys (out of primary guarantee scope; used for limit analysis). + +### 8.2 Security games + +#### Game G1: Authorization Unforgeability + +**Goal:** `A` convinces an honest verifier to accept an authorization proof for role/registry context without access to valid issuer/admin credential material. + +**Win condition:** verifier accepts with non-negligible probability. + +**Target claim:** advantage is negligible under BBS+ proof unforgeability and sound registry-state verification. + +#### Game G2: Transcript Rebinding / MITM Relay + +**Goal:** `A` relays or reuses a proof generated in session `s1` to authenticate in distinct session `s2`. + +**Win condition:** verifier in `s2` accepts proof bound to transcript hash from `s1`. + +**Target claim:** advantage is negligible when proof challenge includes session transcript hash and transcript computation is collision-resistant/domain-separated. + +#### Game G3: Credential Payload Confidentiality + +**Goal:** `A` distinguishes which of two equal-length credential payloads was transmitted to recipient. + +**Experiment:** challenger encrypts one of two adversary-chosen messages under recipient-targeted envelope; `A` gets ciphertext plus all node-observable metadata. + +**Win condition:** `A` guesses chosen message with non-negligible advantage over 1/2. + +**Target claim:** IND-CPA/AEAD security inherited from ECDH + HKDF + ChaCha20-Poly1305 under standard assumptions. + +#### Game G4: Payload Integrity Under Active Modification + +**Goal:** `A` modifies in-transit encrypted payload so recipient accepts altered plaintext as valid credential material. + +**Win condition:** recipient accepts altered semantics without detection. + +**Target claim:** negligible advantage due to AEAD integrity, strict outer/inner context binding checks (registry and role), and credential verification against certified registry role keys before storage. + +#### Game G5: Malicious Node Knowledge Gain + +**Goal:** malicious node learns hidden credential witness data beyond explicitly disclosed proof attributes and unavoidable metadata. + +**Win condition:** extraction of non-disclosed witness information from observed protocol transcripts. + +**Target claim:** negligible advantage under zero-knowledge properties of BBS+ presentations and secure transport assumptions. + +### 8.3 Out-of-scope and partial-scope properties + +- **Availability/censorship resistance:** not guaranteed by core cryptography. +- **Traffic-analysis resistance:** improved only when deployed over anonymity networks (Tor/I2P); timing correlation may still exist. +- **Endpoint key compromise:** breaks guarantees for affected principal by definition. + +## 9. Why This Matters for Private Internet Services + +This model enables service architectures where users do not have to trust server operators with authorization truth. Combined with Tor/I2P-style deployment, it enables anonymous, trust-minimized service access with practical latency and operational cost. + +In short: this is a lightweight trustless layer for federated Internet services, positioned between classic server trust and heavyweight blockchain consensus. + +## 10. Conclusion + +Trustless federation is a practical design point for privacy-preserving Internet systems. It preserves the deployability and performance of federated services while shifting security from operator trust to cryptographic verification. This architecture is not a replacement for blockchains or traditional protocols; it is a complementary middle layer for applications that need stronger privacy and weaker server trust without paying the cost of global consensus. + diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index fdc070a..f78c919 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -709,7 +709,7 @@ dependencies = [ [[package]] name = "zkac" -version = "0.5.1" +version = "0.7.1" dependencies = [ "blake2", "chacha20poly1305", diff --git a/pyproject.toml b/pyproject.toml index a88c8a9..814343c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "zkac" -version = "0.5.1" +version = "0.7.1" description = "Zero-Knowledge Access Control: BBS+ anonymous credentials with encrypted transport" readme = "README.md" requires-python = ">=3.10" @@ -33,3 +33,9 @@ zkac-node = { path = "cli", editable = true } features = ["python"] module-name = "zkac._zkac" python-source = "python" + +[dependency-groups] +dev = [ + "matplotlib>=3.10.9", + "nbconvert>=7.17.1", +] diff --git a/python/zkac/__init__.py b/python/zkac/__init__.py index bad41f2..dc538b2 100644 --- a/python/zkac/__init__.py +++ b/python/zkac/__init__.py @@ -4,11 +4,10 @@ ZKAC — Zero-Knowledge Access Control BBS+ anonymous credentials (BLS12-381) with encrypted transport (Ristretto255 / X25519). """ -__version__ = "0.5.1" +__version__ = "0.7.1" from zkac._zkac import ( MAX_BBS_AUTH_PROOF_BYTES, - PIR_RECORD_BYTES, Keypair, PublicKey, BbsIssuer, @@ -25,11 +24,6 @@ from zkac._zkac import ( IssuanceKeypair, encrypt_for_admin, decrypt_from_admin, - PirDatabase, - PirServer, - PirClient, - PirClientState, - grant_detection_tag, Session, Node, PendingConnect, @@ -38,7 +32,6 @@ from zkac._zkac import ( __all__ = [ "__version__", "MAX_BBS_AUTH_PROOF_BYTES", - "PIR_RECORD_BYTES", "Keypair", "PublicKey", "BbsIssuer", @@ -55,11 +48,6 @@ __all__ = [ "IssuanceKeypair", "encrypt_for_admin", "decrypt_from_admin", - "PirDatabase", - "PirServer", - "PirClient", - "PirClientState", - "grant_detection_tag", "Session", "Node", "PendingConnect", diff --git a/scripts/e2e_two_clients_timing.py b/scripts/e2e_two_clients_timing.py deleted file mode 100644 index e8e89d8..0000000 --- a/scripts/e2e_two_clients_timing.py +++ /dev/null @@ -1,246 +0,0 @@ -#!/usr/bin/env python3 -""" -E2E smoke + timing: one server, clients A (admin) and B (recipient). - - 1. A creates a registry with 3 roles - 2. A posts one or more grants to B (same role ``beta``) - 3. Time B's mailbox fetch and permission-style checks (same phases as - ``zkac-node credentials list B --server …``: local creds, list_pending, has_credential) - -Default (no args): one grant, asserts one pending ``beta`` grant. - -Scaling: ``--sizes 2,5,25,50`` runs a fresh server + pool for each size (all grants -to B), prints a timing table (``list_pending`` = tags + PIR full-row decode per match). - -Run from repo root, e.g.: - - uv run python scripts/e2e_two_clients_timing.py - uv run python scripts/e2e_two_clients_timing.py --sizes 2,5,25,50 -""" - -from __future__ import annotations - -import argparse -import base64 -import os -import socket -import sys -import tempfile -import threading -import time -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT / "python")) -sys.path.insert(0, str(ROOT / "cli")) - - -def _free_port() -> int: - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.bind(("127.0.0.1", 0)) - _, port = s.getsockname() - s.close() - return port - - -def _run_scaled_sizes(sizes: list[int]) -> int: - from zkac_cli import client, store - from zkac_cli.server import _ServerStore, serve - - def log(msg: str) -> None: - print(msg, flush=True) - - rows: list[tuple[int, float, float, float, float, float, float]] = [] - - for n in sizes: - td = tempfile.mkdtemp(prefix="zkac-e2e-") - os.environ["ZKAC_HOME"] = td - port = _free_port() - server = f"127.0.0.1:{port}" - server_dd = Path(td) / "srv" - server_dd.mkdir(parents=True) - - ss = _ServerStore(server_dd) - kp = ss.load_or_create_keypair() - pk_b64 = base64.b64encode(kp.public_key().to_bytes()).decode() - - store.create_user("A") - store.create_user("B") - store.pin_server("A", server, pk_b64) - store.pin_server("B", server, pk_b64) - - t_srv = threading.Thread( - target=lambda: serve(str(server_dd), "127.0.0.1", port), - daemon=True, - ) - t_srv.start() - time.sleep(0.25) - - t_setup0 = time.perf_counter() - rid = client.create_registry("A", server, ["alpha", "beta", "gamma"]) - t_after_create = time.perf_counter() - b_pk = store.load_identity("B")["issuance_pk"].hex() - for _ in range(n): - client.grant("A", server, rid, "beta", b_pk) - t_gr1 = time.perf_counter() - - t_loc0 = time.perf_counter() - local_creds = store.list_credentials("B") - t_loc1 = time.perf_counter() - - t_mail0 = time.perf_counter() - pending = client.list_pending("B", server) - t_mail1 = time.perf_counter() - - t_perm0 = time.perf_counter() - for g in pending: - r = g.get("registry_id") - role = g.get("role_name") - if r not in (None, "?") and role not in (None, "?"): - store.has_credential("B", r, role) - t_perm1 = time.perf_counter() - - create_ms = (t_after_create - t_setup0) * 1000 - grant_ms = (t_gr1 - t_after_create) * 1000 - local_ms = (t_loc1 - t_loc0) * 1000 - mail_ms = (t_mail1 - t_mail0) * 1000 - perm_ms = (t_perm1 - t_perm0) * 1000 - total_ms = (t_perm1 - t_loc0) * 1000 - - rows.append((n, create_ms, grant_ms, local_ms, mail_ms, perm_ms, total_ms)) - - log( - f"n={n}: create_registry={create_ms:.0f} ms N×grant={grant_ms:.0f} ms " - f"list_pending={mail_ms:.0f} ms pending={len(pending)} ZKAC_HOME={td}" - ) - if len(pending) != n: - print(f"ERROR: expected {n} pending, got {len(pending)}", flush=True) - return 1 - for p in pending: - if p.get("role_name") != "beta" or p.get("registry_id") != rid: - print(f"ERROR: bad pending row {p!r}", flush=True) - return 1 - - print() - print( - "pool_n | create_registry (ms) | N×grant (ms) | list_local (ms) | " - "list_pending mailbox (ms) | has_cred (ms) | cred_list_total (ms)" - ) - print("-" * 120) - for n, create_ms, grant_ms, local_ms, mail_ms, perm_ms, total_ms in rows: - print( - f"{n:6d} | {create_ms:20.1f} | {grant_ms:12.1f} | {local_ms:15.3f} | " - f"{mail_ms:25.1f} | {perm_ms:12.3f} | {total_ms:20.1f}" - ) - print("OK") - return 0 - - -def main() -> int: - parser = argparse.ArgumentParser(description="ZKAC e2e timing (mailbox / credentials list)") - parser.add_argument( - "--sizes", - default=None, - metavar="N,N,...", - help="comma-separated pool sizes (each run: fresh server, N grants to B, then list_pending)", - ) - args = parser.parse_args() - - if args.sizes is not None: - sizes = [int(x.strip()) for x in args.sizes.split(",") if x.strip()] - if not sizes or any(x < 1 for x in sizes): - print("error: --sizes must be positive integers", file=sys.stderr) - return 2 - return _run_scaled_sizes(sizes) - - from zkac_cli import client, store - from zkac_cli.server import _ServerStore, serve - - def log(msg: str) -> None: - print(msg, flush=True) - - td = tempfile.mkdtemp(prefix="zkac-e2e-") - os.environ["ZKAC_HOME"] = td - port = _free_port() - server = f"127.0.0.1:{port}" - server_dd = Path(td) / "srv" - server_dd.mkdir(parents=True) - - ss = _ServerStore(server_dd) - kp = ss.load_or_create_keypair() - pk_b64 = base64.b64encode(kp.public_key().to_bytes()).decode() - - store.create_user("A") - store.create_user("B") - store.pin_server("A", server, pk_b64) - store.pin_server("B", server, pk_b64) - - t_srv = threading.Thread( - target=lambda: serve(str(server_dd), "127.0.0.1", port), - daemon=True, - ) - t_srv.start() - time.sleep(0.25) - log("server thread up") - - t0 = time.perf_counter() - rid = client.create_registry("A", server, ["alpha", "beta", "gamma"]) - t_create = time.perf_counter() - log(f"registry created ({(t_create - t0) * 1000:.0f} ms)") - - b_pk = store.load_identity("B")["issuance_pk"].hex() - client.grant("A", server, rid, "beta", b_pk) - t_grant = time.perf_counter() - log(f"grant posted ({(t_grant - t_create) * 1000:.0f} ms)") - - log("B mailbox + permission-style checks (same work as `credentials list`) …") - t_loc0 = time.perf_counter() - local_creds = store.list_credentials("B") - t_loc1 = time.perf_counter() - - t_mail0 = time.perf_counter() - pending = client.list_pending("B", server) - t_mail1 = time.perf_counter() - - t_perm0 = time.perf_counter() - for g in pending: - r = g.get("registry_id") - role = g.get("role_name") - if r not in (None, "?") and role not in (None, "?"): - store.has_credential("B", r, role) - t_perm1 = time.perf_counter() - - log(f"ZKAC_HOME={td}") - log(f"server={server} registry={rid[:24]}…") - print(f"create_registry: {(t_create - t0) * 1000:.1f} ms") - print(f"grant: {(t_grant - t_create) * 1000:.1f} ms") - print( - f"list_local_creds(B): {(t_loc1 - t_loc0) * 1000:.3f} ms ({len(local_creds)} on disk)" - ) - print( - f"list_pending(mailbox): {(t_mail1 - t_mail0) * 1000:.1f} ms " - f"({len(pending)} match(es); tags + PIR row + decrypt)" - ) - print( - f"has_credential checks: {(t_perm1 - t_perm0) * 1000:.3f} ms " - f"({len(pending)} grant(s))" - ) - print( - f"credentials_list_total: {(t_perm1 - t_loc0) * 1000:.1f} ms " - "(local + mailbox + permission flags)" - ) - for p in pending: - print( - f" pending: registry={p.get('registry_id', '?')[:16]}… " - f"role={p.get('role_name')} idx={p.get('pool_index')}" - ) - - assert len(pending) == 1, pending - assert pending[0].get("role_name") == "beta" - assert pending[0].get("registry_id") == rid - print("OK") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/fuzz-libfuzzer.sh b/scripts/fuzz-libfuzzer.sh index c067137..1a1980a 100755 --- a/scripts/fuzz-libfuzzer.sh +++ b/scripts/fuzz-libfuzzer.sh @@ -22,14 +22,19 @@ FUZZ_TIME="${FUZZ_TIME:-60}" FUZZ_RUNS="${FUZZ_RUNS:-}" SANITIZER="${SANITIZER:-none}" -TARGETS=( - handshake_respond - handshake_initiator_complete - session_decrypt - replay_sequence - crypto_deserialize - bbs_verify_presentation -) +discover_targets() { + local list + # `cargo fuzz list` reflects the targets registered in `fuzz/Cargo.toml`. + # Keep target discovery dynamic so CI fuzz-smoke automatically covers new code. + list="$(cargo fuzz list)" + if [[ -z "$list" ]]; then + echo "No fuzz targets found via 'cargo fuzz list'." >&2 + exit 1 + fi + while IFS= read -r line; do + [[ -n "$line" ]] && printf '%s\n' "$line" + done <<<"$list" +} run_one() { local name="$1" @@ -47,7 +52,7 @@ if [[ $# -gt 0 ]]; then run_one "$name" done else - for name in "${TARGETS[@]}"; do + while IFS= read -r name; do run_one "$name" - done + done < <(discover_targets) fi diff --git a/src/credential/registry.rs b/src/credential/registry.rs index a20c44a..ef770cb 100644 --- a/src/credential/registry.rs +++ b/src/credential/registry.rs @@ -150,6 +150,13 @@ impl RegistryState { let num_roles = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize; pos += 4; + let remaining = data.len().saturating_sub(pos); + let min_role_entry_len = 32 + 4 + 8; // role_id + pk_len + epoch + let max_roles_by_len = remaining / min_role_entry_len; + if num_roles > max_roles_by_len { + return Err(err("invalid role count for provided state length")); + } + let mut roles = Vec::with_capacity(num_roles); for _ in 0..num_roles { if data.len() < pos + 32 + 4 { diff --git a/src/issuance.rs b/src/issuance.rs index b2598cb..00fe566 100644 --- a/src/issuance.rs +++ b/src/issuance.rs @@ -7,14 +7,17 @@ use chacha20poly1305::aead::{Aead, KeyInit}; use chacha20poly1305::ChaCha20Poly1305; use hkdf::Hkdf; +use rand::rngs::OsRng; use rand::{CryptoRng, RngCore}; use sha2::Sha256; +use subtle::ConstantTimeEq; use x25519_dalek::{EphemeralSecret, PublicKey as X25519Public, StaticSecret}; use crate::{Error, Result}; -const ISSUANCE_HKDF_INFO: &[u8] = b"zkac-issuance-v1"; -const NONCE_BYTES: [u8; 12] = [0u8; 12]; +const ISSUANCE_HKDF_INFO_USER_TO_ADMIN: &[u8] = b"zkac-issuance-v1:user-to-admin"; +const ISSUANCE_HKDF_INFO_ADMIN_TO_USER: &[u8] = b"zkac-issuance-v1:admin-to-user"; +const NONCE_LEN: usize = 12; /// Admin-side issuance keypair (X25519 static secret for DH). pub struct IssuanceKeypair { @@ -47,22 +50,26 @@ impl IssuanceKeypair { pub fn decrypt(&self, eph_pk_bytes: &[u8; 32], ciphertext: &[u8]) -> Result> { let eph_pk = X25519Public::from(*eph_pk_bytes); let shared = self.secret.diffie_hellman(&eph_pk); - let key = derive_key(shared.as_bytes()); + if is_zero(shared.as_bytes()) { + return Err(Error::CredentialError("issuance DH produced zero shared secret".into())); + } + let key = derive_key(shared.as_bytes(), ISSUANCE_HKDF_INFO_USER_TO_ADMIN); let cipher = ChaCha20Poly1305::new_from_slice(&key) .map_err(|_| Error::CredentialError("issuance key derivation failed".into()))?; - cipher.decrypt(&NONCE_BYTES.into(), ciphertext) - .map_err(|_| Error::DecryptionFailed) + decrypt_with_prefixed_nonce(&cipher, ciphertext) } /// Encrypt a response to the user (uses same shared secret). pub fn encrypt(&self, eph_pk_bytes: &[u8; 32], plaintext: &[u8]) -> Result> { let eph_pk = X25519Public::from(*eph_pk_bytes); let shared = self.secret.diffie_hellman(&eph_pk); - let key = derive_key(shared.as_bytes()); + if is_zero(shared.as_bytes()) { + return Err(Error::CredentialError("issuance DH produced zero shared secret".into())); + } + let key = derive_key(shared.as_bytes(), ISSUANCE_HKDF_INFO_ADMIN_TO_USER); let cipher = ChaCha20Poly1305::new_from_slice(&key) .map_err(|_| Error::CredentialError("issuance key derivation failed".into()))?; - cipher.encrypt(&NONCE_BYTES.into(), plaintext) - .map_err(|_| Error::CredentialError("issuance encryption failed".into())) + encrypt_with_random_nonce(&cipher, plaintext) } } @@ -78,12 +85,14 @@ pub fn encrypt_for_admin( let admin_pk = X25519Public::from(*admin_issuance_pk); let shared = eph_secret.diffie_hellman(&admin_pk); + if is_zero(shared.as_bytes()) { + return Err(Error::CredentialError("issuance DH produced zero shared secret".into())); + } - let key = derive_key(shared.as_bytes()); + let key = derive_key(shared.as_bytes(), ISSUANCE_HKDF_INFO_USER_TO_ADMIN); let cipher = ChaCha20Poly1305::new_from_slice(&key) .map_err(|_| Error::CredentialError("issuance key derivation failed".into()))?; - let ciphertext = cipher.encrypt(&NONCE_BYTES.into(), plaintext) - .map_err(|_| Error::CredentialError("issuance encryption failed".into()))?; + let ciphertext = encrypt_with_random_nonce(&cipher, plaintext)?; Ok((*eph_public.as_bytes(), ciphertext)) } @@ -97,20 +106,50 @@ pub fn decrypt_from_admin( let eph_secret = StaticSecret::from(*eph_secret_bytes); let admin_pk = X25519Public::from(*admin_issuance_pk); let shared = eph_secret.diffie_hellman(&admin_pk); + if is_zero(shared.as_bytes()) { + return Err(Error::CredentialError("issuance DH produced zero shared secret".into())); + } - let key = derive_key(shared.as_bytes()); + let key = derive_key(shared.as_bytes(), ISSUANCE_HKDF_INFO_ADMIN_TO_USER); let cipher = ChaCha20Poly1305::new_from_slice(&key) .map_err(|_| Error::CredentialError("issuance key derivation failed".into()))?; - cipher.decrypt(&NONCE_BYTES.into(), ciphertext) + decrypt_with_prefixed_nonce(&cipher, ciphertext) +} + +fn derive_key(shared_secret: &[u8], info: &[u8]) -> [u8; 32] { + let hk = Hkdf::::new(None, shared_secret); + let mut key = [0u8; 32]; + hk.expand(info, &mut key) + .expect("HKDF expand should not fail for 32 bytes"); + key +} + +fn encrypt_with_random_nonce(cipher: &ChaCha20Poly1305, plaintext: &[u8]) -> Result> { + let mut nonce = [0u8; NONCE_LEN]; + OsRng.fill_bytes(&mut nonce); + let mut out = Vec::with_capacity(NONCE_LEN + plaintext.len() + 16); + out.extend_from_slice(&nonce); + let mut ct = cipher + .encrypt(&nonce.into(), plaintext) + .map_err(|_| Error::CredentialError("issuance encryption failed".into()))?; + out.append(&mut ct); + Ok(out) +} + +fn decrypt_with_prefixed_nonce(cipher: &ChaCha20Poly1305, blob: &[u8]) -> Result> { + if blob.len() < NONCE_LEN { + return Err(Error::DecryptionFailed); + } + let (nonce, ciphertext) = blob.split_at(NONCE_LEN); + let mut nonce_arr = [0u8; NONCE_LEN]; + nonce_arr.copy_from_slice(nonce); + cipher + .decrypt(&nonce_arr.into(), ciphertext) .map_err(|_| Error::DecryptionFailed) } -fn derive_key(shared_secret: &[u8]) -> [u8; 32] { - let hk = Hkdf::::new(None, shared_secret); - let mut key = [0u8; 32]; - hk.expand(ISSUANCE_HKDF_INFO, &mut key) - .expect("HKDF expand should not fail for 32 bytes"); - key +fn is_zero(bytes: &[u8; 32]) -> bool { + bytes.ct_eq(&[0u8; 32]).into() } #[cfg(test)] @@ -154,10 +193,10 @@ mod tests { // Encrypt commitment let shared = user_secret.diffie_hellman(&X25519Public::from(admin_pk)); - let key = derive_key(shared.as_bytes()); + let key = derive_key(shared.as_bytes(), ISSUANCE_HKDF_INFO_USER_TO_ADMIN); let cipher = ChaCha20Poly1305::new_from_slice(&key).unwrap(); let commitment = b"test commitment"; - let encrypted = cipher.encrypt(&NONCE_BYTES.into(), commitment.as_slice()).unwrap(); + let encrypted = encrypt_with_random_nonce(&cipher, commitment.as_slice()).unwrap(); // Admin decrypts let decrypted = admin_kp.decrypt(&eph_pk, &encrypted).unwrap(); @@ -172,6 +211,21 @@ mod tests { assert_eq!(dec_response, response); } + #[test] + fn encrypt_uses_random_nonce_prefix() { + let admin_kp = IssuanceKeypair::generate(&mut OsRng); + let admin_pk = admin_kp.public_key_bytes(); + let payload = b"same payload"; + + let (_eph_pk_a, c1) = encrypt_for_admin(OsRng, &admin_pk, payload).unwrap(); + let (_eph_pk_b, c2) = encrypt_for_admin(OsRng, &admin_pk, payload).unwrap(); + + assert!(c1.len() > NONCE_LEN); + assert!(c2.len() > NONCE_LEN); + assert_ne!(&c1[..NONCE_LEN], &c2[..NONCE_LEN]); + assert_ne!(c1, c2); + } + #[test] fn wrong_key_fails() { let admin_kp = IssuanceKeypair::generate(&mut OsRng); diff --git a/src/lib.rs b/src/lib.rs index d0923d2..a31b617 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,6 @@ pub mod credential; pub mod error; pub mod issuance; pub mod node; -pub mod pir; pub mod registry_manager; pub mod transport; diff --git a/src/pir/db.rs b/src/pir/db.rs deleted file mode 100644 index 8629a68..0000000 --- a/src/pir/db.rs +++ /dev/null @@ -1,46 +0,0 @@ -use blake2::Blake2b512; -use digest::Digest; - -/// Database of fixed-length records, packed as a `cells_per_record × n_records` -/// column-major matrix of mod-p cells (p = 256, so one byte = one cell). -pub struct Database { - /// Row-major: data[i * n_records + j] = record j's byte i. - data: Vec, - n_records: usize, - record_bytes: usize, -} - -impl Database { - /// Pack `records` (each padded/truncated to `record_bytes`) into a query-ready matrix. - pub fn new(records: &[&[u8]], record_bytes: usize) -> Self { - let n_records = records.len(); - let cells = record_bytes; - let mut data = vec![0u32; cells * n_records]; - for (j, rec) in records.iter().enumerate() { - let len = rec.len().min(record_bytes); - for i in 0..len { - data[i * n_records + j] = rec[i] as u32; - } - } - Database { data, n_records, record_bytes } - } - - pub fn data(&self) -> &[u32] { &self.data } - pub fn n_records(&self) -> usize { self.n_records } - pub fn record_bytes(&self) -> usize { self.record_bytes } - pub fn cells_per_record(&self) -> usize { self.record_bytes } - - /// BLAKE2b-256 commitment over (n_records, record_bytes, all packed cells). - pub fn version(&self) -> [u8; 32] { - let mut h = Blake2b512::new(); - h.update((self.n_records as u64).to_le_bytes()); - h.update((self.record_bytes as u64).to_le_bytes()); - for &val in &self.data { - h.update(val.to_le_bytes()); - } - let digest = h.finalize(); - let mut v = [0u8; 32]; - v.copy_from_slice(&digest[..32]); - v - } -} diff --git a/src/pir/doublepir.rs b/src/pir/doublepir.rs deleted file mode 100644 index 29ee8a9..0000000 --- a/src/pir/doublepir.rs +++ /dev/null @@ -1,372 +0,0 @@ -//! SimplePIR-style single-server PIR (Henzinger–Hong–Corrigan-Gibbs–Meiklejohn–Vaikuntanathan, USENIX Security 2023), -//! first-layer construction over ``Z_{2^{32}}`` with plaintext modulus ``p = 256``. -//! -//! ``H = D · A^T`` (offline hint), query ``q = A^T s + e + Δ·e_index``, answer ``D·q``; client removes ``H·s`` and rounds. -//! The grant **mailbox** uses single-hop retrieval: each PIR row contains one full encrypted mailbox row -//! (ephemeral key, detection tag, ciphertext, ciphertext digest), padded to ``RECORD_BYTES``. -//! -//! Security: decisional LWE with ``n = N_LWE``, ``q = 2^32``, ``σ = 6.4``. - -use blake2::Blake2b512; -use digest::Digest; -use rand::rngs::OsRng; -use rand::Rng; - -use super::db::Database; -use super::lwe; -use super::params::*; - -const HINT_MAGIC: &[u8; 8] = b"ZKACSP1\0"; -const CLIENT_STATE_MAGIC: &[u8; 8] = b"ZKACSPst"; - -// ── Hints (public matrix seed + precomputed ``H = D · A^T``) ───────── - -pub struct Hints { - seed: [u8; 32], - n_records: usize, - cells_per_record: usize, - /// Row-major ``cells_per_record × N_LWE`` matrix (mod ``2^32``). - hint: Vec, -} - -impl Hints { - pub fn n_records(&self) -> usize { - self.n_records - } - pub fn cells_per_record(&self) -> usize { - self.cells_per_record - } - pub fn seed(&self) -> &[u8; 32] { - &self.seed - } - pub fn hint_matrix(&self) -> &[u32] { - &self.hint - } - - pub fn serialize(&self) -> Vec { - let n = self.cells_per_record * N_LWE; - debug_assert_eq!(self.hint.len(), n); - let mut buf = Vec::with_capacity(8 + 32 + 8 + 8 + n * 4); - buf.extend_from_slice(HINT_MAGIC); - buf.extend_from_slice(&self.seed); - buf.extend_from_slice(&(self.n_records as u64).to_le_bytes()); - buf.extend_from_slice(&(self.cells_per_record as u64).to_le_bytes()); - for &v in &self.hint { - buf.extend_from_slice(&v.to_le_bytes()); - } - buf - } - - pub fn deserialize(data: &[u8]) -> Result { - if data.len() < 8 + 32 + 8 + 8 { - return Err("hint data too short"); - } - if &data[0..8] != HINT_MAGIC { - return Err("unknown PIR hint format (expected SimplePIR v1)"); - } - let mut seed = [0u8; 32]; - seed.copy_from_slice(&data[8..40]); - let n_records = u64::from_le_bytes(data[40..48].try_into().unwrap()) as usize; - let cells_per_record = u64::from_le_bytes(data[48..56].try_into().unwrap()) as usize; - let n = cells_per_record - .checked_mul(N_LWE) - .ok_or("overflow")?; - let need = 56 + n * 4; - if data.len() != need { - return Err("hint data length mismatch"); - } - let hint: Vec = data[56..] - .chunks_exact(4) - .map(|c| u32::from_le_bytes(c.try_into().unwrap())) - .collect(); - Ok(Hints { - seed, - n_records, - cells_per_record, - hint, - }) - } - - pub fn version(&self) -> [u8; 32] { - let mut h = Blake2b512::new(); - h.update(b"zkac-pir-hints-sp1-v1"); - h.update(self.seed); - h.update((self.n_records as u64).to_le_bytes()); - h.update((self.cells_per_record as u64).to_le_bytes()); - for &v in &self.hint { - h.update(v.to_le_bytes()); - } - let digest = h.finalize(); - let mut out = [0u8; 32]; - out.copy_from_slice(&digest[..32]); - out - } -} - -// ── Server ────────────────────────────────────────────────────────── - -pub struct Server { - db: Database, - hints: Hints, -} - -impl Server { - pub fn new(db: Database) -> Self { - let ell = db.cells_per_record(); - let m = db.n_records(); - let mut seed = [0u8; 32]; - OsRng.fill(&mut seed); - let hint = if m == 0 { - Vec::new() - } else { - let a = lwe::gen_matrix(&seed, N_LWE, m); - lwe::mat_mul_bt(db.data(), &a, ell, m, N_LWE) - }; - let hints = Hints { - seed, - n_records: m, - cells_per_record: ell, - hint, - }; - Server { db, hints } - } - - pub fn hints(&self) -> &Hints { - &self.hints - } - - pub fn version(&self) -> [u8; 32] { - self.hints.version() - } - - /// ``answer = D · q`` (mod ``2^32``); ``q`` is ``m`` ``u32`` limbs serialized as little-endian bytes. - pub fn answer(&self, query: &[u8]) -> Vec { - let m = self.hints.n_records; - let ell = self.hints.cells_per_record; - if m == 0 { - return Vec::new(); - } - let q: Vec = query - .chunks_exact(4) - .map(|c| u32::from_le_bytes(c.try_into().unwrap())) - .collect(); - if q.len() != m { - return Vec::new(); - } - lwe::mat_vec_mul(self.db.data(), &q, ell, m) - } - - pub fn n_records(&self) -> usize { - self.hints.n_records - } - pub fn record_bytes(&self) -> usize { - self.hints.cells_per_record - } -} - -// ── Client ────────────────────────────────────────────────────────── - -pub struct ClientState { - pub secret: Vec, -} - -pub struct Client { - hints: Hints, -} - -impl Client { - pub fn new(hints: Hints) -> Self { - Client { hints } - } - - pub fn version(&self) -> [u8; 32] { - self.hints.version() - } - pub fn n_records(&self) -> usize { - self.hints.n_records - } - pub fn record_bytes(&self) -> usize { - self.hints.cells_per_record - } - - pub fn query(&self, index: usize) -> (Vec, ClientState) { - assert!(index < self.hints.n_records, "PIR query index out of range"); - let mut rng = OsRng; - let s = lwe::sample_uniform_vec(&mut rng, N_LWE); - let e = lwe::sample_error_vec(&mut rng, self.hints.n_records); - let a = lwe::gen_matrix(self.hints.seed(), N_LWE, self.hints.n_records); - let mut q = lwe::mat_t_vec_mul(&a, &s, N_LWE, self.hints.n_records); - for j in 0..self.hints.n_records { - q[j] = q[j].wrapping_add(e[j]); - } - q[index] = q[index].wrapping_add(DELTA); - (serialize_vec(&q), ClientState { secret: s }) - } - - pub fn decode(&self, answer: &[u32], state: &ClientState) -> Vec { - let h_s = lwe::mat_vec_mul( - self.hints.hint_matrix(), - &state.secret, - self.hints.cells_per_record, - N_LWE, - ); - (0..self.hints.cells_per_record) - .map(|i| lwe::round_to_plaintext(answer[i].wrapping_sub(h_s[i]))) - .collect() - } -} - -pub fn serialize_vec(v: &[u32]) -> Vec { - let mut buf = Vec::with_capacity(v.len() * 4); - for &val in v { - buf.extend_from_slice(&val.to_le_bytes()); - } - buf -} - -pub fn deserialize_vec(data: &[u8]) -> Vec { - data.chunks_exact(4) - .map(|c| u32::from_le_bytes(c.try_into().unwrap())) - .collect() -} - -/// Persist [`ClientState`] for WASM / out-of-process flows. -pub fn serialize_client_state(st: &ClientState) -> Vec { - let mut buf = Vec::with_capacity(8 + st.secret.len() * 4); - buf.extend_from_slice(CLIENT_STATE_MAGIC); - for &x in &st.secret { - buf.extend_from_slice(&x.to_le_bytes()); - } - buf -} - -pub fn deserialize_client_state(data: &[u8]) -> Result { - if data.len() < 8 + N_LWE * 4 { - return Err("client state too short"); - } - if &data[0..8] != CLIENT_STATE_MAGIC { - return Err("bad client state magic"); - } - let mut secret = vec![0u32; N_LWE]; - let mut off = 8; - for i in 0..N_LWE { - secret[i] = u32::from_le_bytes(data[off..off + 4].try_into().map_err(|_| "parse")?); - off += 4; - } - if off != data.len() { - return Err("client state trailing garbage"); - } - Ok(ClientState { secret }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_test_records(n: usize, rec_bytes: usize) -> Vec> { - (0..n) - .map(|i| { - let mut rec = vec![0u8; rec_bytes]; - rec[0] = i as u8; - rec[1] = (i.wrapping_mul(37) & 0xFF) as u8; - if rec_bytes > 2 { - rec[rec_bytes - 1] = 0xAA; - } - rec - }) - .collect() - } - - #[test] - fn roundtrip_small() { - let records = make_test_records(8, 24); - let refs: Vec<&[u8]> = records.iter().map(|r| r.as_slice()).collect(); - let db = Database::new(&refs, 24); - let server = Server::new(db); - let hints_bytes = server.hints().serialize(); - let client = Client::new(Hints::deserialize(&hints_bytes).unwrap()); - - for target in 0..8 { - let (query, state) = client.query(target); - let answer = server.answer(&query); - let decoded = client.decode(&answer, &state); - assert_eq!(decoded, records[target]); - } - } - - #[test] - fn roundtrip_serialized_query_answer() { - let records = make_test_records(4, 64); - let refs: Vec<&[u8]> = records.iter().map(|r| r.as_slice()).collect(); - let db = Database::new(&refs, 64); - let server = Server::new(db); - let client = Client::new(Hints::deserialize(&server.hints().serialize()).unwrap()); - - let (q, state) = client.query(2); - let ans = server.answer(&q); - let decoded = client.decode(&ans, &state); - assert_eq!(decoded, records[2]); - } - - #[test] - fn version_changes_on_rebuild() { - let records = make_test_records(4, 64); - let refs: Vec<&[u8]> = records.iter().map(|r| r.as_slice()).collect(); - let db1 = Database::new(&refs, 64); - let s1 = Server::new(db1); - let v1 = s1.version(); - - let db2 = Database::new(&refs, 64); - let s2 = Server::new(db2); - let v2 = s2.version(); - - assert_ne!(v1, v2); - } - - #[test] - fn hints_roundtrip() { - let records = make_test_records(4, 64); - let refs: Vec<&[u8]> = records.iter().map(|r| r.as_slice()).collect(); - let db = Database::new(&refs, 64); - let server = Server::new(db); - - let original = server.hints(); - let bytes = original.serialize(); - let restored = Hints::deserialize(&bytes).unwrap(); - - assert_eq!(original.seed(), restored.seed()); - assert_eq!(original.n_records(), restored.n_records()); - assert_eq!(original.cells_per_record(), restored.cells_per_record()); - assert_eq!(original.hint_matrix(), restored.hint_matrix()); - assert_eq!(original.version(), restored.version()); - } - - #[test] - fn client_state_serialize_roundtrip() { - let records = make_test_records(3, 32); - let refs: Vec<&[u8]> = records.iter().map(|r| r.as_slice()).collect(); - let db = Database::new(&refs, 32); - let server = Server::new(db); - let client = Client::new(Hints::deserialize(&server.hints().serialize()).unwrap()); - let (q, st) = client.query(1); - let bytes = serialize_client_state(&st); - let st2 = deserialize_client_state(&bytes).unwrap(); - let ans = server.answer(&q); - let d1 = client.decode(&ans, &st); - let d2 = client.decode(&ans, &st2); - assert_eq!(d1, d2); - } - - #[test] - fn wire_sizes_match_simple_pir_formulas() { - let m: u64 = 100; - let ell = RECORD_BYTES as u64; - let n = N_LWE as u64; - let hint = 56u64 + ell * n * 4u64; - let query = m * 4u64; - let answer = ell * 4u64; - assert_eq!(hint, 56 + (RECORD_BYTES as u64) * (N_LWE as u64) * 4); - assert!(hint > query && hint > answer); - let _ = (hint, query, answer); - } -} diff --git a/src/pir/lwe.rs b/src/pir/lwe.rs deleted file mode 100644 index 854e25c..0000000 --- a/src/pir/lwe.rs +++ /dev/null @@ -1,103 +0,0 @@ -use rand::{Rng, SeedableRng}; -use rand::rngs::StdRng; - -use super::params::*; - -/// Deterministically generate a `rows × cols` matrix of uniform u32 values from `seed`. -pub fn gen_matrix(seed: &[u8; 32], rows: usize, cols: usize) -> Vec { - let mut rng = StdRng::from_seed(*seed); - (0..rows * cols).map(|_| rng.gen()).collect() -} - -/// Sample one value from a discrete Gaussian with stddev SIGMA (Box-Muller, rounded). -fn sample_gaussian(rng: &mut impl Rng) -> i32 { - let u1: f64 = rng.gen_range(1e-10f64..1.0); - let u2: f64 = rng.gen(); - let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos(); - (z * SIGMA).round() as i32 -} - -/// Sample a vector of `len` small error values (discrete Gaussian, stored as u32 mod 2^32). -pub fn sample_error_vec(rng: &mut impl Rng, len: usize) -> Vec { - (0..len).map(|_| sample_gaussian(rng) as u32).collect() -} - -pub fn sample_uniform_vec(rng: &mut impl Rng, len: usize) -> Vec { - (0..len).map(|_| rng.gen()).collect() -} - -/// C = A · B^T (mod 2^32). -/// A is `rows_a × inner` (row-major), B is `rows_b × inner` (row-major). -/// Result C is `rows_a × rows_b`. -pub fn mat_mul_bt(a: &[u32], b: &[u32], rows_a: usize, inner: usize, rows_b: usize) -> Vec { - let mut c = vec![0u32; rows_a * rows_b]; - for i in 0..rows_a { - let a_off = i * inner; - for k in 0..rows_b { - let b_off = k * inner; - let mut acc = 0u64; - for j in 0..inner { - acc = acc.wrapping_add( - (a[a_off + j] as u64).wrapping_mul(b[b_off + j] as u64), - ); - } - c[i * rows_b + k] = acc as u32; - } - } - c -} - -/// c = A · v (mod 2^32). A is `rows × cols`, v is `cols`-length. -pub fn mat_vec_mul(a: &[u32], v: &[u32], rows: usize, cols: usize) -> Vec { - let mut c = vec![0u32; rows]; - for i in 0..rows { - let off = i * cols; - let mut acc = 0u64; - for j in 0..cols { - acc = acc.wrapping_add((a[off + j] as u64).wrapping_mul(v[j] as u64)); - } - c[i] = acc as u32; - } - c -} - -/// c = A^T · v (mod 2^32). A is `rows × cols`, v is `rows`-length. Result is `cols`-length. -pub fn mat_t_vec_mul(a: &[u32], v: &[u32], rows: usize, cols: usize) -> Vec { - let mut c = vec![0u64; cols]; - for k in 0..rows { - let v_k = v[k] as u64; - let off = k * cols; - for j in 0..cols { - c[j] = c[j].wrapping_add(v_k.wrapping_mul(a[off + j] as u64)); - } - } - c.iter().map(|&x| x as u32).collect() -} - -/// Round from Z_{2^32} to Z_p. Recovers the plaintext byte from Δ·m + noise. -pub fn round_to_plaintext(val: u32) -> u8 { - (val.wrapping_add(DELTA / 2) >> 24) as u8 -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn round_exact() { - for m in 0u32..=255 { - let encoded = m.wrapping_mul(DELTA); - assert_eq!(round_to_plaintext(encoded), m as u8); - } - } - - #[test] - fn round_with_small_noise() { - for m in 0u32..=255 { - for noise in [-100i32, -1, 0, 1, 100] { - let encoded = m.wrapping_mul(DELTA).wrapping_add(noise as u32); - assert_eq!(round_to_plaintext(encoded), m as u8); - } - } - } -} diff --git a/src/pir/mod.rs b/src/pir/mod.rs deleted file mode 100644 index cd2d3ba..0000000 --- a/src/pir/mod.rs +++ /dev/null @@ -1,69 +0,0 @@ -pub mod params; -pub mod lwe; -pub mod db; -/// Production single-server SimplePIR implementation. -pub mod doublepir; - -pub use params::RECORD_BYTES; -pub use db::Database; -pub use doublepir::{ - deserialize_client_state, deserialize_vec, serialize_client_state, serialize_vec, Client, - ClientState, Hints, Server, -}; - -use blake2::Blake2b512; -use digest::Digest; -use x25519_dalek::{PublicKey as X25519Public, StaticSecret}; - -/// Compute a 16-byte detection tag for grant discovery. -/// -/// tag = BLAKE2b-512("zkac-grant-tag" || X25519(sk, pk))[..16] -/// -/// Both sides of the DH produce the same tag, so the sender (admin) can -/// publish it alongside the grant ciphertext, and the recipient can match -/// it locally without PIR. -pub fn detection_tag(secret_key: &[u8; 32], public_key: &[u8; 32]) -> [u8; 16] { - let sk = StaticSecret::from(*secret_key); - let pk = X25519Public::from(*public_key); - let shared = sk.diffie_hellman(&pk); - - let mut h = Blake2b512::new(); - h.update(b"zkac-grant-tag"); - h.update(shared.as_bytes()); - let digest = h.finalize(); - - let mut tag = [0u8; 16]; - tag.copy_from_slice(&digest[..16]); - tag -} - -#[cfg(test)] -mod tests { - use super::*; - use rand::rngs::OsRng; - - #[test] - fn detection_tag_symmetric() { - let sk_a = x25519_dalek::StaticSecret::random_from_rng(&mut OsRng); - let pk_a = X25519Public::from(&sk_a); - let sk_b = x25519_dalek::StaticSecret::random_from_rng(&mut OsRng); - let pk_b = X25519Public::from(&sk_b); - - let tag_ab = detection_tag(&sk_a.to_bytes(), pk_b.as_bytes()); - let tag_ba = detection_tag(&sk_b.to_bytes(), pk_a.as_bytes()); - assert_eq!(tag_ab, tag_ba); - } - - #[test] - fn detection_tag_different_keys() { - let sk_a = x25519_dalek::StaticSecret::random_from_rng(&mut OsRng); - let sk_b = x25519_dalek::StaticSecret::random_from_rng(&mut OsRng); - let sk_c = x25519_dalek::StaticSecret::random_from_rng(&mut OsRng); - let pk_b = X25519Public::from(&sk_b); - let pk_c = X25519Public::from(&sk_c); - - let tag1 = detection_tag(&sk_a.to_bytes(), pk_b.as_bytes()); - let tag2 = detection_tag(&sk_a.to_bytes(), pk_c.as_bytes()); - assert_ne!(tag1, tag2); - } -} diff --git a/src/pir/params.rs b/src/pir/params.rs deleted file mode 100644 index 7139d94..0000000 --- a/src/pir/params.rs +++ /dev/null @@ -1,18 +0,0 @@ -/// LWE dimension — controls security level. -pub const N_LWE: usize = 1024; - -/// Plaintext modulus: each cell holds one byte (p = 256). -pub const P: u32 = 256; - -/// Scaling factor Δ = 2^32 / p = 2^24. Maps plaintext [0,255] into Z_{2^32}. -pub const DELTA: u32 = 1 << 24; - -/// Discrete Gaussian standard deviation for LWE error sampling. -pub const SIGMA: f64 = 6.4; - -/// Fixed record size for PIR (bytes). Must fit one full encrypted mailbox row. -/// -/// Mailbox retrieval is single-hop: the PIR row carries ``(eph_pk, to_tag, ciphertext, digest)`` -/// so clients do not reveal a stable ``grant_id`` in a second fetch round-trip. -/// Hint size scales as ``O(RECORD_BYTES · N_LWE · n)``. -pub const RECORD_BYTES: usize = 2048; diff --git a/src/python.rs b/src/python.rs index f42fa85..746d0d4 100644 --- a/src/python.rs +++ b/src/python.rs @@ -330,6 +330,9 @@ impl PyRoleRegistry { if role_id.len() != 32 { return Err(PyValueError::new_err("role_id must be 32 bytes")); } + if proof_bytes.len() > crate::node::MAX_BBS_AUTH_PROOF_BYTES { + return Ok(false); + } let mut rid = [0u8; 32]; rid.copy_from_slice(role_id); let pres = bbs::Presentation::from_bytes(proof_bytes.to_vec()); @@ -490,6 +493,9 @@ impl PyRegistryManager { } fn verify_admin(&self, registry_id: &[u8], proof_bytes: &[u8], nonce: &[u8]) -> PyResult { + if proof_bytes.len() > crate::node::MAX_BBS_AUTH_PROOF_BYTES { + return Ok(false); + } let rid = to_32(registry_id, "registry_id")?; match self.inner.verify_admin(&rid, proof_bytes, nonce) { Ok(()) => Ok(true), @@ -499,6 +505,9 @@ impl PyRegistryManager { } fn verify_presentation(&self, registry_id: &[u8], role_id: &[u8], proof_bytes: &[u8], nonce: &[u8]) -> PyResult { + if proof_bytes.len() > crate::node::MAX_BBS_AUTH_PROOF_BYTES { + return Ok(false); + } let rid = to_32(registry_id, "registry_id")?; let roid = to_32(role_id, "role_id")?; let pres = bbs::Presentation::from_bytes(proof_bytes.to_vec()); @@ -833,187 +842,11 @@ impl PyNode { } } -// ── PIR (SimplePIR, LWE-based) ───────────────────────────────────── - -#[pyclass(name = "PirDatabase")] -pub struct PyPirDatabase { - inner: crate::pir::Database, -} - -#[pymethods] -impl PyPirDatabase { - #[new] - fn new(records: Vec>, record_bytes: usize) -> PyResult { - if record_bytes == 0 { - return Err(PyValueError::new_err("record_bytes must be > 0")); - } - let refs: Vec<&[u8]> = records.iter().map(|r| r.as_slice()).collect(); - Ok(PyPirDatabase { - inner: crate::pir::Database::new(&refs, record_bytes), - }) - } - - fn version<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { - PyBytes::new(py, &self.inner.version()) - } - - #[getter] - fn record_bytes(&self) -> usize { - self.inner.record_bytes() - } - - #[getter] - fn n_records(&self) -> usize { - self.inner.n_records() - } -} - -#[pyclass(name = "PirServer")] -pub struct PyPirServer { - inner: crate::pir::Server, -} - -#[pymethods] -impl PyPirServer { - #[new] - fn new(db: &PyPirDatabase) -> Self { - // Database is packed into u32 cells; we need to reconstruct from the - // inner data. Since Database doesn't implement Clone, rebuild it from - // the raw cell data. - // - // Actually, Server::new takes ownership of Database. We reconstruct - // by re-packing from the stored cell matrix. This is slightly wasteful - // but keeps the API simple. - let n = db.inner.n_records(); - let rb = db.inner.record_bytes(); - let cells = db.inner.cells_per_record(); - - let mut records: Vec> = Vec::with_capacity(n); - for j in 0..n { - let mut rec = vec![0u8; rb]; - for i in 0..cells { - rec[i] = db.inner.data()[i * n + j] as u8; - } - records.push(rec); - } - let refs: Vec<&[u8]> = records.iter().map(|r| r.as_slice()).collect(); - let new_db = crate::pir::Database::new(&refs, rb); - PyPirServer { - inner: crate::pir::Server::new(new_db), - } - } - - fn hints<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { - PyBytes::new(py, &self.inner.hints().serialize()) - } - - fn version<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { - PyBytes::new(py, &self.inner.version()) - } - - fn answer<'py>(&self, py: Python<'py>, query: &[u8]) -> PyResult> { - let ans = self.inner.answer(query); - Ok(PyBytes::new(py, &crate::pir::serialize_vec(&ans))) - } - - #[getter] - fn n_records(&self) -> usize { - self.inner.n_records() - } - - #[getter] - fn record_bytes(&self) -> usize { - self.inner.record_bytes() - } -} - -#[pyclass(name = "PirClientState")] -pub struct PyPirClientState { - inner: Option, -} - -#[pyclass(name = "PirClient")] -pub struct PyPirClient { - inner: crate::pir::Client, -} - -#[pymethods] -impl PyPirClient { - #[new] - fn new(hints: &[u8], n_records: usize, record_bytes: usize) -> PyResult { - let h = crate::pir::Hints::deserialize(hints) - .map_err(|e| PyValueError::new_err(e.to_string()))?; - if h.n_records() != n_records { - return Err(PyValueError::new_err("n_records mismatch with hints")); - } - if h.cells_per_record() != record_bytes { - return Err(PyValueError::new_err("record_bytes mismatch with hints")); - } - Ok(PyPirClient { - inner: crate::pir::Client::new(h), - }) - } - - fn version<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { - PyBytes::new(py, &self.inner.version()) - } - - fn query<'py>( - &self, - py: Python<'py>, - index: usize, - ) -> PyResult<(Bound<'py, PyBytes>, PyPirClientState)> { - if index >= self.inner.n_records() { - return Err(PyValueError::new_err("index out of range")); - } - let (q, state) = self.inner.query(index); - Ok(( - PyBytes::new(py, &q), - PyPirClientState { inner: Some(state) }, - )) - } - - fn decode<'py>( - &self, - py: Python<'py>, - answer: &[u8], - state: &mut PyPirClientState, - ) -> PyResult> { - let st = state.inner.take().ok_or_else(|| { - PyValueError::new_err("PirClientState already consumed") - })?; - let ans = crate::pir::deserialize_vec(answer); - let expect = self.inner.record_bytes(); - if ans.len() != expect { - return Err(PyValueError::new_err(format!( - "answer length mismatch: got {} u32, want {} (one limb per record byte)", - ans.len(), - expect - ))); - } - let decoded = self.inner.decode(&ans, &st); - Ok(PyBytes::new(py, &decoded)) - } -} - -#[pyfunction] -fn grant_detection_tag<'py>( - py: Python<'py>, - secret_key: &[u8], - public_key: &[u8], -) -> PyResult> { - let sk = to_32(secret_key, "secret_key")?; - let pk = to_32(public_key, "public_key")?; - let tag = crate::pir::detection_tag(&sk, &pk); - Ok(PyBytes::new(py, &tag)) -} - // ── Module ─────────────────────────────────────────────────────────── #[pymodule] fn _zkac(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add("MAX_BBS_AUTH_PROOF_BYTES", crate::node::MAX_BBS_AUTH_PROOF_BYTES)?; - m.add("PIR_RECORD_BYTES", crate::pir::RECORD_BYTES)?; // Transport identity (ristretto255) m.add_class::()?; m.add_class::()?; @@ -1035,12 +868,6 @@ fn _zkac(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_function(wrap_pyfunction!(encrypt_for_admin, m)?)?; m.add_function(wrap_pyfunction!(decrypt_from_admin, m)?)?; - // PIR (LWE-based, single-server) - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_function(wrap_pyfunction!(grant_detection_tag, m)?)?; // Transport m.add_class::()?; m.add_class::()?; diff --git a/tests/test_pir.py b/tests/test_pir.py deleted file mode 100644 index 137f74c..0000000 --- a/tests/test_pir.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Tests for LWE-based SimplePIR via the Python bindings.""" - -import json -import os - -import zkac - - -def _pad(entry: dict) -> bytes: - raw = json.dumps(entry, separators=(",", ":"), sort_keys=True).encode() - return raw + b"\x00" * (zkac.PIR_RECORD_BYTES - len(raw)) - - -def test_roundtrip_small(): - """Build a small DB, query every index, verify each decode is correct.""" - records = [ - _pad({"id": i, "data": f"record-{i}"}) - for i in range(8) - ] - db = zkac.PirDatabase(records, zkac.PIR_RECORD_BYTES) - server = zkac.PirServer(db) - hints = bytes(server.hints()) - client = zkac.PirClient(hints, 8, zkac.PIR_RECORD_BYTES) - - for target in range(8): - q, state = client.query(target) - answer = server.answer(q) - decoded = bytes(client.decode(answer, state)) - assert decoded == records[target], f"mismatch at index {target}" - - -def test_roundtrip_medium(): - """64 records padded to ``PIR_RECORD_BYTES`` (must fit LWE-encoded plaintext).""" - records = [] - for i in range(64): - entry = {"id": i, "payload": os.urandom(32).hex()} - records.append(_pad(entry)) - db = zkac.PirDatabase(records, zkac.PIR_RECORD_BYTES) - server = zkac.PirServer(db) - hints = bytes(server.hints()) - client = zkac.PirClient(hints, 64, zkac.PIR_RECORD_BYTES) - - for target in [0, 1, 31, 32, 63]: - q, state = client.query(target) - answer = server.answer(q) - decoded = bytes(client.decode(answer, state)) - assert decoded == records[target] - - -def test_version_changes_on_rebuild(): - """Rebuilding from the same data with a new seed gives a different version.""" - records = [_pad({"i": i}) for i in range(4)] - s1 = zkac.PirServer(zkac.PirDatabase(records, zkac.PIR_RECORD_BYTES)) - s2 = zkac.PirServer(zkac.PirDatabase(records, zkac.PIR_RECORD_BYTES)) - assert bytes(s1.version()) != bytes(s2.version()) - - -def test_hints_serialize_roundtrip(): - records = [_pad({"i": i}) for i in range(4)] - server = zkac.PirServer(zkac.PirDatabase(records, zkac.PIR_RECORD_BYTES)) - hints1 = bytes(server.hints()) - client = zkac.PirClient(hints1, 4, zkac.PIR_RECORD_BYTES) - assert bytes(client.version()) == bytes(server.version()) - - -def test_detection_tag_symmetric(): - """grant_detection_tag(a_sk, b_pk) == grant_detection_tag(b_sk, a_pk).""" - kp_a = zkac.IssuanceKeypair() - kp_b = zkac.IssuanceKeypair() - tag_ab = bytes(zkac.grant_detection_tag(kp_a.secret_bytes(), kp_b.public_key_bytes())) - tag_ba = bytes(zkac.grant_detection_tag(kp_b.secret_bytes(), kp_a.public_key_bytes())) - assert tag_ab == tag_ba - assert len(tag_ab) == 16 - - -def test_detection_tag_distinct(): - kp_a = zkac.IssuanceKeypair() - kp_b = zkac.IssuanceKeypair() - kp_c = zkac.IssuanceKeypair() - t1 = bytes(zkac.grant_detection_tag(kp_a.secret_bytes(), kp_b.public_key_bytes())) - t2 = bytes(zkac.grant_detection_tag(kp_a.secret_bytes(), kp_c.public_key_bytes())) - assert t1 != t2 diff --git a/uv.lock b/uv.lock index fcb0e71..1b7645f 100644 --- a/uv.lock +++ b/uv.lock @@ -25,6 +25,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, ] +[[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, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "bleach" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" }, +] + +[package.optional-dependencies] +css = [ + { name = "tinycss2" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -146,6 +185,171 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, ] +[[package]] +name = "contourpy" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, + { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" }, + { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, + { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" }, + { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, + { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" }, + { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, + { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, + { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, + { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, + { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, + { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + [[package]] name = "debugpy" version = "1.8.20" @@ -184,6 +388,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -205,6 +418,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] +[[package]] +name = "fastjsonschema" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, +] + [[package]] name = "flask" version = "3.1.3" @@ -235,6 +457,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/98/107728ce3f430b5481eb426ccc5e1f7c8ab0bd01eaf231c62a8d528ff721/flask_sock-0.7.0-py3-none-any.whl", hash = "sha256:caac4d679392aaf010d02fabcf73d52019f5bdaf1c9c131ec5a428cb3491204a", size = 3982, upload-time = "2023-10-02T22:32:41.778Z" }, ] +[[package]] +name = "fonttools" +version = "4.62.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/532ed43808b469c807e8cb6b21358da3fe6fd51486b3a8c93db0bb5d957f/fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c", size = 2873740, upload-time = "2026-03-13T13:52:11.822Z" }, + { url = "https://files.pythonhosted.org/packages/85/e4/2318d2b430562da7227010fb2bb029d2fa54d7b46443ae8942bab224e2a0/fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a", size = 2417649, upload-time = "2026-03-13T13:52:14.605Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/40f15523b5188598018e7956899fed94eb7debec89e2dd70cb4a8df90492/fonttools-4.62.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3", size = 4935213, upload-time = "2026-03-13T13:52:17.399Z" }, + { url = "https://files.pythonhosted.org/packages/42/09/7dbe3d7023f57d9b580cfa832109d521988112fd59dddfda3fddda8218f9/fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23", size = 4892374, upload-time = "2026-03-13T13:52:20.175Z" }, + { url = "https://files.pythonhosted.org/packages/d1/2d/84509a2e32cb925371560ef5431365d8da2183c11d98e5b4b8b4e42426a5/fonttools-4.62.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d", size = 4911856, upload-time = "2026-03-13T13:52:22.777Z" }, + { url = "https://files.pythonhosted.org/packages/a5/80/df28131379eed93d9e6e6fccd3bf6e3d077bebbfe98cc83f21bbcd83ed02/fonttools-4.62.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae", size = 5031712, upload-time = "2026-03-13T13:52:25.14Z" }, + { url = "https://files.pythonhosted.org/packages/3d/03/3c8f09aad64230cd6d921ae7a19f9603c36f70930b00459f112706f6769a/fonttools-4.62.1-cp310-cp310-win32.whl", hash = "sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed", size = 1507878, upload-time = "2026-03-13T13:52:28.149Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/f53f626f8f3e89f4cadd8fc08f3452c8fd182c951ad5caa35efac22b29ab/fonttools-4.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9", size = 1556766, upload-time = "2026-03-13T13:52:30.814Z" }, + { url = "https://files.pythonhosted.org/packages/88/39/23ff32561ec8d45a4d48578b4d241369d9270dc50926c017570e60893701/fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7", size = 2871039, upload-time = "2026-03-13T13:52:33.127Z" }, + { url = "https://files.pythonhosted.org/packages/24/7f/66d3f8a9338a9b67fe6e1739f47e1cd5cee78bd3bc1206ef9b0b982289a5/fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14", size = 2416346, upload-time = "2026-03-13T13:52:35.676Z" }, + { url = "https://files.pythonhosted.org/packages/aa/53/5276ceba7bff95da7793a07c5284e1da901cf00341ce5e2f3273056c0cca/fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7", size = 5100897, upload-time = "2026-03-13T13:52:38.102Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a1/40a5c4d8e28b0851d53a8eeeb46fbd73c325a2a9a165f290a5ed90e6c597/fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b", size = 5071078, upload-time = "2026-03-13T13:52:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/d378fca4c65ea1956fee6d90ace6e861776809cbbc5af22388a090c3c092/fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1", size = 5076908, upload-time = "2026-03-13T13:52:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d9/ae6a1d0693a4185a84605679c8a1f719a55df87b9c6e8e817bfdd9ef5936/fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416", size = 5202275, upload-time = "2026-03-13T13:52:46.591Z" }, + { url = "https://files.pythonhosted.org/packages/54/6c/af95d9c4efb15cabff22642b608342f2bd67137eea6107202d91b5b03184/fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53", size = 2293075, upload-time = "2026-03-13T13:52:48.711Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/bf54c5b3f2be34e1f143e6db838dfdc54f2ffa3e68c738934c82f3b2a08d/fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2", size = 2344593, upload-time = "2026-03-13T13:52:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/47/d4/dbacced3953544b9a93088cc10ef2b596d348c983d5c67a404fa41ec51ba/fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974", size = 2870219, upload-time = "2026-03-13T13:52:53.664Z" }, + { url = "https://files.pythonhosted.org/packages/66/9e/a769c8e99b81e5a87ab7e5e7236684de4e96246aae17274e5347d11ebd78/fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9", size = 2414891, upload-time = "2026-03-13T13:52:56.493Z" }, + { url = "https://files.pythonhosted.org/packages/69/64/f19a9e3911968c37e1e620e14dfc5778299e1474f72f4e57c5ec771d9489/fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936", size = 5033197, upload-time = "2026-03-13T13:52:59.179Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8a/99c8b3c3888c5c474c08dbfd7c8899786de9604b727fcefb055b42c84bba/fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392", size = 4988768, upload-time = "2026-03-13T13:53:02.761Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c6/0f904540d3e6ab463c1243a0d803504826a11604c72dd58c2949796a1762/fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04", size = 4971512, upload-time = "2026-03-13T13:53:05.678Z" }, + { url = "https://files.pythonhosted.org/packages/29/0b/5cbef6588dc9bd6b5c9ad6a4d5a8ca384d0cea089da31711bbeb4f9654a6/fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d", size = 5122723, upload-time = "2026-03-13T13:53:08.662Z" }, + { url = "https://files.pythonhosted.org/packages/4a/47/b3a5342d381595ef439adec67848bed561ab7fdb1019fa522e82101b7d9c/fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c", size = 2281278, upload-time = "2026-03-13T13:53:10.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/b1/0c2ab56a16f409c6c8a68816e6af707827ad5d629634691ff60a52879792/fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42", size = 2331414, upload-time = "2026-03-13T13:53:13.992Z" }, + { url = "https://files.pythonhosted.org/packages/3b/56/6f389de21c49555553d6a5aeed5ac9767631497ac836c4f076273d15bd72/fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79", size = 2865155, upload-time = "2026-03-13T13:53:16.132Z" }, + { url = "https://files.pythonhosted.org/packages/03/c5/0e3966edd5ec668d41dfe418787726752bc07e2f5fd8c8f208615e61fa89/fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe", size = 2412802, upload-time = "2026-03-13T13:53:18.878Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/e6ac4b44026de7786fe46e3bfa0c87e51d5d70a841054065d49cd62bb909/fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68", size = 5013926, upload-time = "2026-03-13T13:53:21.379Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1", size = 4964575, upload-time = "2026-03-13T13:53:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/46/76/7d051671e938b1881670528fec69cc4044315edd71a229c7fd712eaa5119/fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069", size = 4953693, upload-time = "2026-03-13T13:53:26.569Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" }, + { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, + { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, + { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, + { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, + { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, + { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, + { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, + { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, + { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, + { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -389,6 +668,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "jupyter-client" version = "8.8.0" @@ -418,6 +724,139 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, ] +[[package]] +name = "jupyterlab-pygments" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/f8/06549565caa026e540b7e7bab5c5a90eb7ca986015f4c48dace243cd24d9/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374", size = 122802, upload-time = "2026-03-09T13:12:37.515Z" }, + { url = "https://files.pythonhosted.org/packages/84/eb/8476a0818850c563ff343ea7c9c05dcdcbd689a38e01aa31657df01f91fa/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd", size = 66216, upload-time = "2026-03-09T13:12:38.812Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/f9c8a6b4c21aed4198566e45923512986d6cef530e7263b3a5f823546561/kiwisolver-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476", size = 63917, upload-time = "2026-03-09T13:12:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0e/ba4ae25d03722f64de8b2c13e80d82ab537a06b30fc7065183c6439357e3/kiwisolver-1.5.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22", size = 1628776, upload-time = "2026-03-09T13:12:41.976Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e4/3f43a011bc8a0860d1c96f84d32fa87439d3feedf66e672fef03bf5e8bac/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b", size = 1228164, upload-time = "2026-03-09T13:12:44.002Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/3a901559a1e0c218404f9a61a93be82d45cb8f44453ba43088644980f033/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e", size = 1246656, upload-time = "2026-03-09T13:12:45.557Z" }, + { url = "https://files.pythonhosted.org/packages/87/9e/f78c466ea20527822b95ad38f141f2de1dcd7f23fb8716b002b0d91bbe59/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb", size = 1295562, upload-time = "2026-03-09T13:12:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/0a/66/fd0e4a612e3a286c24e6d6f3a5428d11258ed1909bc530ba3b59807fd980/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537", size = 2178473, upload-time = "2026-03-09T13:12:50.254Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8e/6cac929e0049539e5ee25c1ee937556f379ba5204840d03008363ced662d/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4", size = 2274035, upload-time = "2026-03-09T13:12:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d3/9d0c18f1b52ea8074b792452cf17f1f5a56bd0302a85191f405cfbf9da16/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c", size = 2443217, upload-time = "2026-03-09T13:12:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/45/2a/6e19368803a038b2a90857bf4ee9e3c7b667216d045866bf22d3439fd75e/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede", size = 2249196, upload-time = "2026-03-09T13:12:55.057Z" }, + { url = "https://files.pythonhosted.org/packages/75/2b/3f641dfcbe72e222175d626bacf2f72c3b34312afec949dd1c50afa400f5/kiwisolver-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2", size = 73389, upload-time = "2026-03-09T13:12:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/da/88/299b137b9e0025d8982e03d2d52c123b0a2b159e84b0ef1501ef446339cf/kiwisolver-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875", size = 64782, upload-time = "2026-03-09T13:12:57.609Z" }, + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/17/6f/6fd4f690a40c2582fa34b97d2678f718acf3706b91d270c65ecb455d0a06/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4", size = 59606, upload-time = "2026-03-09T13:15:40.81Z" }, + { url = "https://files.pythonhosted.org/packages/82/a0/2355d5e3b338f13ce63f361abb181e3b6ea5fffdb73f739b3e80efa76159/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca", size = 57537, upload-time = "2026-03-09T13:15:42.071Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b9/1d50e610ecadebe205b71d6728fd224ce0e0ca6aba7b9cbe1da049203ac5/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f", size = 79888, upload-time = "2026-03-09T13:15:43.317Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ee/b85ffcd75afed0357d74f0e6fc02a4507da441165de1ca4760b9f496390d/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed", size = 77584, upload-time = "2026-03-09T13:15:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/6b/dd/644d0dde6010a8583b4cd66dd41c5f83f5325464d15c4f490b3340ab73b4/kiwisolver-1.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc", size = 73390, upload-time = "2026-03-09T13:15:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -503,6 +942,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "matplotlib" +version = "3.10.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/6f/340b04986e67aac6f66c5145ce68bf72c64bed30f92c8913499a6e6b8f99/matplotlib-3.10.9-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77210dce9cb8153dffc967efaae990543392563d5a376d4dd8539bebcb0ed217", size = 8296625, upload-time = "2026-04-24T00:11:43.376Z" }, + { url = "https://files.pythonhosted.org/packages/bb/2f/127081eb83162053ebb9678ceac64220b93a663e0167432566e9c7c82aab/matplotlib-3.10.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1e7698ac9868428e84d2c967424803b2472ff7167d9d6590d4204ed775343c3b", size = 8188790, upload-time = "2026-04-24T00:11:46.556Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b7/d8bcec2626c35f96972bff656299fef4578113ea6193c8fdad324710410c/matplotlib-3.10.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1aa972116abb4c9d201bf245620b433726cb6856f3bef6a78f776a00f5c92d37", size = 8769389, upload-time = "2026-04-24T00:11:48.959Z" }, + { url = "https://files.pythonhosted.org/packages/12/49/b78e214a527ea732033b7f4d37f7afb504d74ba9d134bd47938230dfb8b1/matplotlib-3.10.9-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae2f11957b27ce53497dd4d7b235c4d4f1faf383dfb39d0c5beb833bff883294", size = 9589657, upload-time = "2026-04-24T00:11:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/5f/15/5246f7b43beae19c74dfee651d58d6cc8112e06f77adb4e88cc04f2e3a23/matplotlib-3.10.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b049278ddce116aaa1c1377ebf58adea909132dfce0281cf7e3a1ea9fc2e2c65", size = 9651983, upload-time = "2026-04-24T00:11:54.766Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/5acecfe672ba0fa1b8c0454f69ce155d1e6fc5852fa7206bf9afaf767121/matplotlib-3.10.9-cp310-cp310-win_amd64.whl", hash = "sha256:82834c3c292d24d3a8aae77cd2d20019de69d692a34a970e4fdb8d33e2ea3dda", size = 8199701, upload-time = "2026-04-24T00:11:58.389Z" }, + { url = "https://files.pythonhosted.org/packages/4c/8c/290f021104741fea63769c31494f5324c0cd249bf536a65a4350767b1f22/matplotlib-3.10.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb", size = 8306860, upload-time = "2026-04-24T00:12:01.207Z" }, + { url = "https://files.pythonhosted.org/packages/51/18/325cd32ece1120d1da51cc4e4294c6580190699490183fc2fe8cb6d61ec5/matplotlib-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb", size = 8199254, upload-time = "2026-04-24T00:12:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/79/db/e28c1b83e3680740aa78925f5fb2ae4d16207207419ad75ea9fe604f8676/matplotlib-3.10.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb", size = 8777092, upload-time = "2026-04-24T00:12:06.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/fa/3ce7adfe9ba101748f465211660d9c6374c876b671bdb8c2bb6d347e8b94/matplotlib-3.10.9-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9", size = 9595691, upload-time = "2026-04-24T00:12:09.706Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/6960a76686ed668f2c60f84e9799ba4c0d56abdb36b1577b60c1d061d1ec/matplotlib-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb", size = 9659771, upload-time = "2026-04-24T00:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0d/271aace3342157c64700c9ff4c59c7b392f3dbab393692e8db6fbe7ab96c/matplotlib-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f", size = 8205112, upload-time = "2026-04-24T00:12:15.773Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ee/cb57ad4754f3e7b9174ce6ce66d9205fb827067e48a9f58ac09d7e7d6b77/matplotlib-3.10.9-cp311-cp311-win_arm64.whl", hash = "sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80", size = 8132310, upload-time = "2026-04-24T00:12:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" }, + { url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, + { url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d3/8d4f6afbecb49fc04e060a57c0fce39ea51cc163a6bd87303ccd698e4fa6/matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f", size = 8320331, upload-time = "2026-04-24T00:12:39.688Z" }, + { url = "https://files.pythonhosted.org/packages/63/d9/9e14bc7564bf92d5ffa801ae5fac819ce74b925dfb55e3ebde61a3bbad3e/matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e", size = 8216461, upload-time = "2026-04-24T00:12:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/8a/17/4402d0d14ccf1dfc70932600b68097fbbf9c898a4871d2cbbe79c7801a32/matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f", size = 8790091, upload-time = "2026-04-24T00:12:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0b/322aeec06dd9b91411f92028b37d447342770a24392aa4813e317064dad5/matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838", size = 9605027, upload-time = "2026-04-24T00:12:47.583Z" }, + { url = "https://files.pythonhosted.org/packages/74/88/5f13482f55e7b00bcfc09838b093c2456e1379978d2a146844aae05350ad/matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2", size = 9671269, upload-time = "2026-04-24T00:12:50.878Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/0840fd2f93da988ec660b8ad1984abe9f25d2aed22a5e394ff1c68c88307/matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921", size = 8217588, upload-time = "2026-04-24T00:12:53.784Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/d706d06dd605c49b9f83a2aed8c13e3e5db70697d7a80b7e3d7915de6b17/matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8", size = 8136913, upload-time = "2026-04-24T00:12:56.501Z" }, + { url = "https://files.pythonhosted.org/packages/9b/45/6e32d96978264c8ca8c4b1010adb955a1a49cfaf314e212bbc8908f04a61/matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9", size = 8368019, upload-time = "2026-04-24T00:12:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/0a/c8e3d3bba245f0f7fc424937f8ff7ef77291a36af3edb97ccd78aa93d84f/matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4", size = 8264645, upload-time = "2026-04-24T00:13:01.406Z" }, + { url = "https://files.pythonhosted.org/packages/3d/aa/5bf5a14fe4fed73a4209a155606f8096ff797aad89c6c35179026571133e/matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc", size = 8802194, upload-time = "2026-04-24T00:13:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5e/b4be852d6bba6fd15893fadf91ff26ae49cb91aac789e95dde9d342e664f/matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99", size = 9622684, upload-time = "2026-04-24T00:13:06.647Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/ed428c971139112ef730f62770654d609467346d09d4b62617e1afd68a5a/matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", size = 9680790, upload-time = "2026-04-24T00:13:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/e7/09/052e884aaf2b985c63cb79f715f1d5b6a3eaa7de78f6a52b9dbc077d5b53/matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8", size = 8287571, upload-time = "2026-04-24T00:13:13.087Z" }, + { url = "https://files.pythonhosted.org/packages/f4/38/ae27288e788c35a4250491422f3db7750366fc8c97d6f36fbdecfc1f5518/matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", size = 8188292, upload-time = "2026-04-24T00:13:15.546Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276, upload-time = "2026-04-24T00:13:18.318Z" }, + { url = "https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218, upload-time = "2026-04-24T00:13:20.974Z" }, + { url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145, upload-time = "2026-04-24T00:13:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085, upload-time = "2026-04-24T00:13:25.849Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358, upload-time = "2026-04-24T00:13:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970, upload-time = "2026-04-24T00:13:31.904Z" }, + { url = "https://files.pythonhosted.org/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785, upload-time = "2026-04-24T00:13:34.511Z" }, + { url = "https://files.pythonhosted.org/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999, upload-time = "2026-04-24T00:13:36.962Z" }, + { url = "https://files.pythonhosted.org/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543, upload-time = "2026-04-24T00:13:39.851Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800, upload-time = "2026-04-24T00:13:42.296Z" }, + { url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561, upload-time = "2026-04-24T00:13:45.026Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" }, + { url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333, upload-time = "2026-04-24T00:13:51.008Z" }, + { url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2b/0e92ad0ac446633f928a1563db4aa8add407e1924faf0ded5b95b35afb27/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1872fb212a05b729e649754a72d5da61d03e0554d76e80303b6f83d1d2c0552b", size = 8293058, upload-time = "2026-04-24T00:13:56.339Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/74682fd369f5299ceda438fea2a0662e6383b85c9383fb9cdfcf04713e07/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:985f2238880e2e69093f588f5fe2e46771747febf0649f3cf7f7b7480875317f", size = 8186627, upload-time = "2026-04-24T00:13:58.623Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e8/368aab88f3c4cd8992800f31abfe0670c3e47540ba20a97e9fdbcde594b3/matplotlib-3.10.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6640f75af2c6148293caa0a2b39dd806a492dd66c8a8b04035813e33d0fd2585", size = 8764117, upload-time = "2026-04-24T00:14:01.684Z" }, + { url = "https://files.pythonhosted.org/packages/63/e2/9f66ca6a651a52abfe0d4964ce01439ed34f3f1e119de10ff3a07f403043/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20", size = 8304420, upload-time = "2026-04-24T00:14:04.57Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e8/467c03568218792906aa87b5e7bb379b605e056ed0c74fe00c051786d925/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba", size = 8197981, upload-time = "2026-04-24T00:14:07.233Z" }, + { url = "https://files.pythonhosted.org/packages/6f/87/afead29192170917537934c6aff4b008c805fff7b1ccea0c79120d96beda/matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4", size = 8774002, upload-time = "2026-04-24T00:14:09.816Z" }, +] + [[package]] name = "matplotlib-inline" version = "0.2.1" @@ -539,6 +1053,73 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/2a/afe0193b673a79ffd2e01ad999511b7e9e6b49af02bb3759d82a78c3043d/maturin-1.13.1-py3-none-win_arm64.whl", hash = "sha256:2839024dcd65776abb4759e5bca29941971e095574162a4d335191da4be9ff24", size = 8905575, upload-time = "2026-04-09T15:14:03.891Z" }, ] +[[package]] +name = "mistune" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/84/620cc3f7e3adf6f5067e10f4dbae71295d8f9e16d5d3f9ef97c40f2f592c/mistune-3.2.1.tar.gz", hash = "sha256:7c8e5501d38bac1582e067e46c8343f17d57ea1aaa735823f3aba1fd59c88a28", size = 98003, upload-time = "2026-05-03T14:33:22.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/7f/a946aa4f8752b37102b41e64dca18a1976ac705c3a0d1dfe74d820a02552/mistune-3.2.1-py3-none-any.whl", hash = "sha256:78cdb0ba5e938053ccf63651b352508d2efa9411dc8810bfb05f2dc5140c0048", size = 53749, upload-time = "2026-05-03T14:33:20.551Z" }, +] + +[[package]] +name = "nbclient" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "nbformat" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/91/1c1d5a4b9a9ebba2b4e32b8c852c2975c872aec1fe42ab5e516b2cecd193/nbclient-0.10.4.tar.gz", hash = "sha256:1e54091b16e6da39e297b0ece3e10f6f29f4ac4e8ee515d29f8a7099bd6553c9", size = 62554, upload-time = "2025-12-23T07:45:46.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/a0/5b0c2f11142ed1dddec842457d3f65eaf71a0080894eb6f018755b319c3a/nbclient-0.10.4-py3-none-any.whl", hash = "sha256:9162df5a7373d70d606527300a95a975a47c137776cd942e52d9c7e29ff83440", size = 25465, upload-time = "2025-12-23T07:45:44.51Z" }, +] + +[[package]] +name = "nbconvert" +version = "7.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "bleach", extra = ["css"] }, + { name = "defusedxml" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyterlab-pygments" }, + { name = "markupsafe" }, + { name = "mistune" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pandocfilters" }, + { name = "pygments" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, +] + [[package]] name = "nest-asyncio" version = "1.6.0" @@ -548,6 +1129,154 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, ] +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" }, + { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" }, + { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" }, + { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" }, + { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" }, + { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" }, + { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" }, + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, + { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" }, + { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" }, + { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -557,6 +1286,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pandocfilters" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, +] + [[package]] name = "parso" version = "0.8.6" @@ -578,6 +1316,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, + { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, + { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, +] + [[package]] name = "platformdirs" version = "4.9.4" @@ -663,6 +1499,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -748,6 +1593,142 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + [[package]] name = "simple-websocket" version = "1.1.0" @@ -769,6 +1750,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + [[package]] name = "stack-data" version = "0.6.3" @@ -783,6 +1773,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] +[[package]] +name = "tinycss2" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -881,6 +1883,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + [[package]] name = "werkzeug" version = "3.1.8" @@ -907,7 +1918,7 @@ wheels = [ [[package]] name = "zkac" -version = "0.5.1" +version = "0.7.1" source = { editable = "." } dependencies = [ { name = "ipykernel" }, @@ -927,6 +1938,12 @@ dev = [ { name = "zkac-node" }, ] +[package.dev-dependencies] +dev = [ + { name = "matplotlib" }, + { name = "nbconvert" }, +] + [package.metadata] requires-dist = [ { name = "flask", marker = "extra == 'demo'", specifier = ">=3.0" }, @@ -939,9 +1956,15 @@ requires-dist = [ ] provides-extras = ["cli", "demo", "dev"] +[package.metadata.requires-dev] +dev = [ + { name = "matplotlib", specifier = ">=3.10.9" }, + { name = "nbconvert", specifier = ">=7.17.1" }, +] + [[package]] name = "zkac-node" -version = "0.2.1" +version = "0.7.1" source = { editable = "cli" } dependencies = [ { name = "zkac" },