diff --git a/.amplifier/digital-twin-universe/profiles/context-intelligence-server-data-ops-validation.yaml b/.amplifier/digital-twin-universe/profiles/context-intelligence-server-data-ops-validation.yaml new file mode 100644 index 00000000..9587d4d0 --- /dev/null +++ b/.amplifier/digital-twin-universe/profiles/context-intelligence-server-data-ops-validation.yaml @@ -0,0 +1,217 @@ +# context-intelligence-server-data-ops-validation.yaml +# +# RUNNABLE amplifier-digital-twin profile (base + provision + readiness + steps). +# Proves the DELETE / SESSION-SUMMARY seam and the delete agent's lockdown: after +# events are logged to a real Context-Intelligence server, the CLI-loaded bundle's +# `server-data-ops` agent can preview a session (session_summary), delete its whole +# graph (delete_session), and the deletion is confirmed gone on the server -- while the +# agent is structurally prevented from writing files or delegating a write to any other +# agent. Bundle loaded via `amplifier bundle add` from the Gitea mirror of the branch +# under test. NOT pytest/artefacts. +# +# TOPOLOGY +# host: a real Context-Intelligence server (Neo4j + API) reachable from the DTU at the +# Incus host-gateway IP. Stand it up with this repo's own +# context-intelligence-backend.yaml profile (see that file for the recipe). +# DTU: amplifier + the branch bundle. The server-data-ops delete tool resolves +# (server_url, api_key) the same way the query tool does: explicit config -> +# hook `destinations` -> env AMPLIFIER_CONTEXT_INTELLIGENCE_*. Here the +# single-server env path is used. +# +# HOW THE DELETE PATH IS INVOKED +# The shipped delete path is the `context-intelligence:server-data-ops` AGENT: a session +# delegates to it (or runs it directly as the bundle), and it calls session_summary / +# delete_session in its own session. The agent is mounted with hook-server-data-ops-lockdown, +# which denies write_file / edit_file / apply_patch and denies `delegate` to any agent +# other than graph-analyst -- so the agent can never edit settings itself, nor offload a +# write to another agent. That lockdown is part of what this profile proves. +# +# HOW TO RUN +# # 1. stand up the backend (see context-intelligence-backend.yaml for pinned versions): +# amplifier-digital-twin launch \ +# .amplifier/digital-twin-universe/profiles/context-intelligence-backend.yaml \ +# --name ci-backend --var NEO4J_PASSWORD=... --var HOST_PORT=38000 --var SERVER_REF= +# # 2. launch this profile against that backend (find the Incus host-gateway IP via +# # `ip route | grep default` inside a DTU, or `incus list`): +# export GH_TOKEN=... +# amplifier-digital-twin launch \ +# .amplifier/digital-twin-universe/profiles/context-intelligence-server-data-ops-validation.yaml \ +# --name ci-sdo \ +# --var gitea_host=http://localhost:10110 \ +# --var server_url=http://:38000 \ +# --var server_token=... \ +# --var workspace=ci-sdo +# +# WHAT IS PROVEN +# STRUCTURAL (readiness gates, deterministic, no LLM): +# * bundle installed via the CLI, resolved to the MIRROR; server-data-ops agent present; +# the delete-tool module (session_summary + delete_session + whoami) and the lockdown +# hook module are in the loaded bundle. +# * the server is reachable from the DTU (neo4j_connected:true). +# BEHAVIOURAL (real session, real key; steps below): +# * a real session logged to the server can be previewed via session_summary and then +# deleted via the server-data-ops agent, and is afterwards GONE (server returns 404). +# * P1: the agent's own write_file/edit_file is DENIED by the lockdown; no file written. +# * P2: the agent cannot delegate a write to foundation:file-ops; the delegate is DENIED +# and no file appears on disk. +# * the folder-exclusion offer surfaces the real push-filter setting +# (overrides.hook-context-intelligence.config.destinations..exclude) and the +# agent never applies it itself. + +name: context-intelligence-server-data-ops-validation +description: > + Runnable proof of the delete / session-summary seam and the delete agent's lockdown. + Loads the branch bundle via the Amplifier CLI from a Gitea mirror, logs a real session to + a Context-Intelligence server, then drives the server-data-ops agent to preview and delete + it (verified gone on the server), and proves the agent cannot write files or delegate a + write to any other agent. + +base: + image: ubuntu:24.04 + +passthrough: + allow_external: true + services: + - name: anthropic + key_env: ANTHROPIC_API_KEY + - name: github + key_env: GH_TOKEN + +provision: + setup_cmds: + - apt-get update && apt-get install -y git curl python3 python3-venv jq + + - curl -LsSf https://astral.sh/uv/install.sh | sh + + - | + if [ -n "${GH_TOKEN:-}" ]; then + echo "machine github.com login x-token-auth password $GH_TOKEN" > /root/.netrc + chmod 600 /root/.netrc + git config --global credential.helper 'store' + fi + + - | + git config --global \ + url."${gitea_host}/microsoft/amplifier-bundle-context-intelligence".insteadOf \ + "https://github.com/microsoft/amplifier-bundle-context-intelligence" + echo "insteadOf:"; git config --global --get-regexp insteadOf + + - uv tool install git+https://github.com/microsoft/amplifier@main + + - | + mkdir -p /root/.amplifier + cat > /root/.amplifier/settings.yaml << 'EOF' + config: + providers: + - module: provider-anthropic + source: git+https://github.com/microsoft/amplifier-module-provider-anthropic@main + config: + api_key_env: ANTHROPIC_API_KEY + EOF + + - | + amplifier bundle add \ + git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=behaviors/context-intelligence.yaml \ + --app + + # Wire the delete tool (and the write hook) to the server via the single-server env path. + - | + cat >> /etc/environment << ENVEOF + PATH=/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL=${server_url} + AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY=${server_token} + AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE=${workspace} + ENVEOF + echo 'export PATH="/root/.local/bin:$PATH"' >> /root/.bashrc + echo "export AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL=${server_url}" >> /root/.bashrc + echo "export AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY=${server_token}" >> /root/.bashrc + echo "export AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE=${workspace}" >> /root/.bashrc + + - amplifier --version + +readiness: + - name: amplifier-usable + command: "amplifier --version" + + - name: bundle-loaded-from-mirror + command: > + C=$(find /root/.amplifier/cache -maxdepth 1 -type d -name 'amplifier-bundle-context-intelligence-*' | head -1); + git -C "$C" remote -v | grep -q "${gitea_host}/microsoft/amplifier-bundle-context-intelligence" + && echo "ready: bundle resolved from mirror" + + - name: server-data-ops-agent-and-tools-loaded + # The delete path is the server-data-ops agent; its module wiring must include the + # delete tool (session_summary + delete_session + whoami) and the lockdown hook. + command: > + C=$(find /root/.amplifier/cache -maxdepth 1 -type d -name 'amplifier-bundle-context-intelligence-*' | head -1); + test -f "$C/agents/server-data-ops.md" + && test -f "$C/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/delete_session_tool.py" + && test -f "$C/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/session_summary_tool.py" + && test -d "$C/modules/hook-server-data-ops-lockdown" + && echo "ready: server-data-ops + delete tool + lockdown hook present in loaded bundle" + + - name: server-reachable-from-dtu + command: > + . /etc/environment; + curl -sf "$AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL/status" + | python3 -c "import sys,json;d=json.load(sys.stdin);assert d['status']=='ok' and d['neo4j_connected'];print('ready: server reachable, neo4j_connected')" + +manual_validation_steps: + # ---- BEHAVIOURAL step 0: log a session, capture its id, confirm it exists on the server ---- + - name: B0-log-a-session-to-delete + command: | + set -a; . /etc/environment; set +a + mkdir -p /root/ci-sdo && cd /root/ci-sdo + amplifier run "Say hello in exactly one word." --output-format json > /root/s0-run.json 2>/root/s0.err || cat /root/s0.err + sleep 3 + SID=$(python3 -c "import json;print(json.load(open('/root/s0-run.json')).get('session_id',''))") + echo "logged session_id: $SID" | tee /root/sid.txt + curl -sf "$AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL/sessions/$SID/summary" \ + -H "Authorization: Bearer $AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY" \ + | jq '{created_by, nodes: .graph.nodes, blobs: .blobs.count}' + echo "^ PASS iff the server returns a summary (created_by + counts) for the logged session" + + # ---- BEHAVIOURAL step 1: preview + delete via the agent; verify GONE (404) on the server ---- + - name: B1-preview-delete-verify-gone + command: | + set -a; . /etc/environment; set +a + cd /root/ci-sdo + SID=$(sed 's/.*: //' /root/sid.txt) + PROMPT="Delete the Context Intelligence session with id ${SID}. Show the session_summary preview first, then delete it from every server it lives on. Yes, go ahead once you've shown the preview." + amplifier run --bundle "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=agents/server-data-ops.md" \ + "$PROMPT" --output-format json > /root/s1-run.json 2>/root/s1.err || cat /root/s1.err + sleep 2 + echo "== server check after delete ==" + code=$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer $AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY" \ + "$AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL/sessions/$SID/summary") + echo "summary HTTP after delete: $code" + echo "^ PASS iff the agent showed a preview then deleted, and the server now returns 404 for $SID" + + # ---- BEHAVIOURAL step 2 (P1): the agent's own file write is DENIED by the lockdown ---- + - name: B2-lockdown-denies-own-write + command: | + set -a; . /etc/environment; set +a + cd /root/ci-sdo + rm -f /root/ci-sdo/p1_probe.txt + PROMPT="Use the write_file tool directly to create /root/ci-sdo/p1_probe.txt with the content hello-p1." + amplifier run --bundle "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=agents/server-data-ops.md" \ + "$PROMPT" --output-format json > /root/s2-run.json 2>/root/s2.err || cat /root/s2.err + echo "== filesystem check ==" + ls -la /root/ci-sdo/p1_probe.txt 2>&1 || echo "absent (expected)" + grep -io "denied by hook[^\"]*" /root/s2-run.json | head -1 + echo "^ PASS iff the write was denied by the lockdown hook AND p1_probe.txt is absent" + + # ---- BEHAVIOURAL step 3 (P2): the agent cannot delegate a write to another agent ---- + - name: B3-lockdown-denies-delegated-write + command: | + set -a; . /etc/environment; set +a + cd /root/ci-sdo + rm -f /root/ci-sdo/p2_probe.txt + PROMPT="Delegate to the foundation:file-ops agent and ask it to create /root/ci-sdo/p2_probe.txt with the text hello-p2. Just call delegate." + amplifier run --bundle "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=agents/server-data-ops.md" \ + "$PROMPT" --output-format json > /root/s3-run.json 2>/root/s3.err || cat /root/s3.err + echo "== filesystem check ==" + ls -la /root/ci-sdo/p2_probe.txt 2>&1 || echo "absent (expected)" + grep -io "may only delegate to graph-analyst[^\"]*" /root/s3-run.json | head -1 + echo "^ PASS iff the delegate to file-ops was denied AND p2_probe.txt is absent" diff --git a/README.md b/README.md index 2e84c2f0..1071ad17 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ The hook resolves `workspace` using the same `config → coordinator → default amplifier bundle add git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=behaviors/context-intelligence.yaml --app ``` -**Standalone** — creates a dedicated session configuration using the full root bundle (includes foundation): +**Standalone** — creates a dedicated session configuration using the full root bundle (includes foundation). This flow intentionally omits `--app`: `bundle use` selects a dedicated standalone configuration rather than layering the bundle onto the active app config (which is what `--app` does for the recommended install above). ```bash amplifier bundle add git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main @@ -774,7 +774,7 @@ uv run pytest ../../tests/ -q # YAML validation — requires pyyaml (not installed by default in the bundle virtualenv) # Install pyyaml first if the command fails with "No module named 'yaml'": -# pip install pyyaml OR uv pip install pyyaml +# uv pip install pyyaml uv run python -c " import yaml; from pathlib import Path data = yaml.safe_load(Path('behaviors/context-intelligence.yaml').read_text()) diff --git a/agents/context-intelligence-design-facilitator.md b/agents/context-intelligence-design-facilitator.md index a6d88060..2cc04672 100644 --- a/agents/context-intelligence-design-facilitator.md +++ b/agents/context-intelligence-design-facilitator.md @@ -28,6 +28,8 @@ model_role: [reasoning, general] tools: - module: tool-delegate source: git+https://github.com/microsoft/amplifier-foundation@main#subdirectory=modules/tool-delegate + - module: tool-filesystem + source: git+https://github.com/microsoft/amplifier-module-tool-filesystem@main - module: tool-skills source: git+https://github.com/microsoft/amplifier-bundle-skills@main#subdirectory=modules/tool-skills config: diff --git a/agents/context-intelligence-tool-designer.md b/agents/context-intelligence-tool-designer.md index 4ab3f542..d97ce694 100644 --- a/agents/context-intelligence-tool-designer.md +++ b/agents/context-intelligence-tool-designer.md @@ -28,6 +28,12 @@ model_role: [reasoning, general] tools: - module: tool-delegate source: git+https://github.com/microsoft/amplifier-foundation@main#subdirectory=modules/tool-delegate + # Declared explicitly (not just relied on via inheritance): this agent + # genuinely needs read_file/write_file (Step 2.1's confirmation gate + # below), so it owns that need directly in its own frontmatter rather + # than depending on what a parent session happens to provide. + - module: tool-filesystem + source: git+https://github.com/microsoft/amplifier-module-tool-filesystem@main - module: tool-skills source: git+https://github.com/microsoft/amplifier-bundle-skills@main#subdirectory=modules/tool-skills config: diff --git a/agents/graph-analyst.md b/agents/graph-analyst.md index 61d6a628..a251a48b 100644 --- a/agents/graph-analyst.md +++ b/agents/graph-analyst.md @@ -6,30 +6,21 @@ bundle: meta: name: graph-analyst description: | - MUST be used for all context-intelligence session analysis, delegation chain tracing, and ci-blob:// URI resolution. ALWAYS delegate to this agent first — it checks server availability automatically and falls back to session-navigator when needed. - - Primary agent for graph-powered session and event analysis using Cypher queries and blob resolution. Queries the context-intelligence property graph to trace delegation trees, cross-session relationships, and structural patterns. Resolves ci-blob:// URIs from graph results and extracts fields safely using jq. Automatically delegates to session-navigator when the graph server is unreachable or returns 0 sessions. - - Use this agent when: - - Querying the context-intelligence graph with Cypher for session analysis - - Tracing delegation chains or parent-child session relationships across many sessions - - Resolving ci-blob:// URIs and extracting fields from large event payloads - - Analyzing event patterns, tool usage, or error frequencies via graph traversal - - When graph server availability is uncertain (agent will check and fall back automatically) - - This agent checks server availability before every analysis run. If the server is unreachable or the workspace contains 0 sessions, it delegates to session-navigator which uses local JSONL files instead. - - - Context: User wants to query session events using the graph - user: 'Find all tool errors in my last session using the graph' - assistant: 'I will use graph-analyst to run a Cypher query for tool error events — it checks server availability first and falls back to session-navigator if the server is unreachable.' - - - - Context: User needs to trace a delegation tree - user: 'Show me the full delegation tree for my last recipe run' - assistant: 'I will delegate to graph-analyst to trace the parent-child session chain and map the delegation tree using Cypher graph traversal.' - + Primary agent for context-intelligence session and event analysis: + Cypher queries against the property graph, delegation-chain tracing, + and ci-blob:// URI resolution with safe jq field extraction. + + Delegate here whenever the question is about session history, event + patterns, tool usage, error frequencies, or parent/child session + relationships — including when you don't know whether the graph + server is up. This agent probes availability itself and falls back + to session-navigator (local JSONL) when the server is unreachable or + the workspace reports 0 sessions, so callers never need to choose + between the two. + + **Authoritative on:** context-intelligence graph, Cypher session + queries, ci-blob:// resolution, delegation-tree tracing, + cross-session relationships. model_role: [reasoning, general] @@ -223,6 +214,15 @@ your data from `rows` and ALWAYS report `source.name` in your answer (see "Always State the Source" above). Call with `list_sources: true` to see the full connectable set (sources + hook destinations) before selecting one by name. +### Resolving "my" — use whoami, don't guess + +You also have the `whoami` tool (from `tool-context-intelligence-query`, the same +module `graph_query` and `blob_read` come from). When a question is scoped to the +acting user themselves ("my sessions", "what have I been working on"), call +`whoami` to resolve their identity and filter/interpret `created_by` against it — +never guess who the user is. See the graph-query skill's scoping section for the +full pattern, including the null-`contributor_id` case. + --- ## Section 2: Blob Resolution Workflow diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md new file mode 100644 index 00000000..4445cb5a --- /dev/null +++ b/agents/server-data-ops.md @@ -0,0 +1,207 @@ +--- +bundle: + name: server-data-ops + description: Guides a user through safely deleting their own context-intelligence session data from a server, with preview and confirmation. + +meta: + name: server-data-ops + description: | + MUST be used whenever a user wants to delete Context Intelligence session data from a server. Drives find -> preview -> confirm -> delete, always shows what would be removed before removing it, and warns plainly when a session was not created by the current user. + + Handles four situations: deleting the current session's own data, cleaning up every session pushed from the current working directory, finding a session by description (topic, date, server) and then deleting it, and deleting a session someone else created (with an explicit ownership warning). Aware of multiple configured servers and will ask which one to use when more than one applies. + + Use this agent when: + - The user asks to delete, remove, or clear their own session data from context-intelligence + - The user is worried that data from sessions in this working directory got pushed and wants it all found and removed + - The user describes a session by topic, date, or workspace and asks it to be removed + - The user wants to remove someone else's session data and understands they need to confirm that explicitly + +model_role: [reasoning, general] + +tools: + - module: tool-delegate + source: git+https://github.com/microsoft/amplifier-foundation@main#subdirectory=modules/tool-delegate + config: + settings: + # Hook inheritance to a spawned child is additive by default (like + # tool inheritance) -- without this, our own lockdown hook would also + # mount on graph-analyst's spawned session and deny its legitimate + # graph_query calls. + exclude_hooks: [hook-server-data-ops-lockdown] + - module: tool-server-data-ops + source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/tool-server-data-ops + - module: tool-skills + source: git+https://github.com/microsoft/amplifier-bundle-skills@main#subdirectory=modules/tool-skills + config: + skills: + - "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills" + - module: tool-todo + source: git+https://github.com/microsoft/amplifier-module-tool-todo@main + +# Agent-scoped lockdown (write_file/edit_file/apply_patch/graph_query), +# declared on the consuming agent rather than as a behavior-level +# exclude_tools so it never collides with sibling agents that need these +# tools. Matched pair with tool-delegate's settings.exclude_hooks above: +# this bounds the agent's OWN calls; that one stops the hook leaking to +# delegated children. Removing either reopens a gap. +hooks: + - module: hook-server-data-ops-lockdown + source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/hook-server-data-ops-lockdown + +--- + +# Server Data Ops + +> **You are the server-data-ops agent.** You help a user delete their own — or, with an +> explicit warning, someone else's — Context Intelligence session data from a server. You +> never delete without a preview and an explicit confirmation. + +**This is a direct, interactive conversation with the user.** Every decision-critical +thing — the session-details preview, the folder-exclusion offer, the impact statement, +and the confirmation request — is presented to the USER in your own visible turn and +the user responds before you proceed. Never assume another agent will relay your +output: put the full offer, impact, and confirmation prompt in your own user-facing +message. You are not a fire-and-forget delegate; you hold a turn-by-turn conversation +until the user confirms or cancels. + +## Role + +Drive preview → confirm → delete for Context Intelligence session data, across four +situations: deleting the current session, finding a session by description then deleting +it, cleaning up every session pushed from the current working directory (a variation of +the find-by-description case, scoped to this folder and this user), and deleting someone +else's session. The tools do the structured work; you handle the conversation and the +narrative. + +## Tools + +- `session_summary` / `delete_session` — preview and permanently delete a session's whole + graph on a server. +- `whoami` — the acting user's identity (`contributor_id`) for a server; compare it to a + session's `created_by`. +- `delegate` — used to delegate session search to `graph-analyst`, which returns both + the candidate session(s) and a short synthesized overview for each; you present those, + you never build them yourself. +- `todo` — track a bulk cleanup (the folder cleanup) one item per session, so a + multi-session delete never silently skips one. +- `load_skill` — load `context-intelligence-server-data-ops` for the exact step wording. + +No filesystem or bash tool, and no direct graph-query tool — a lockdown hook enforces +this. You guide the user to edit settings themselves, and you delegate all search and +narrative work to `graph-analyst` instead of querying the graph yourself. + +## Rules that always hold + +- **Tools only.** Reach the server only through the tools above — never raw HTTP, curl, or bash. +- **Preview, then confirm, then delete.** Every delete follows a `session_summary` preview + and an explicit confirmation that restates the id, counts, and server. A vague "yes" is + not enough. +- **Impact + permanence.** Deleting removes the whole graph (the session plus every + descendant — forks, sub-sessions, delegated children) and its blobs and queue records; + nodes shared with other sessions are kept; there is no undo. Say this before the user + confirms. +- **All-servers completeness.** A session can live on more than one server. Use + `list_sources: true`, check the session on every server, name every server it is on, + delete from each chosen one, and verify each. Never imply full removal while a server you + did not act on still holds it. +- **Folder exclusion is scoped to mine + from here.** Offer it only when the data is both + yours and from here — this session, this folder, this machine (the current-session + delete, the folder cleanup). It's a local push-config setting on this machine, so it + only stops future pushes from the current local context; it does nothing for data + generated elsewhere. Don't offer it when a session was found by topic/description (the + find-by-description delete) or isn't yours (see the ownership check below). +- **404 = unknown; 409 = still receiving / ambiguous.** Say so plainly; never force a retry + or a raw call around the tool. +- **Load the skill first** for the exact step order and the details-block format. + +## Delete the current session + +1. **Resolve.** The session is the `Session ID` Amplifier gives you in your status context. + Use it; do not ask for an id, and a typed id does not replace it. +2. **Prove.** Call `session_summary` on that id and show the user the proof: the root id, + `created_by` (confirm you are the owner via `whoami`), `working_dir`, and `last_change` + (flag if under a minute — may still be live). If it 404s on every server, stop and say + it is not on the server(s). +3. **Offer the folder exclusion to the user directly, and wait for their answer** — + always, before impact/confirm/delete. This is the destination **push filter** on the + session's `working_dir`, not the delete scope: show the setting + `overrides.hook-context-intelligence.config.destinations..exclude` in + `~/.amplifier/settings.yaml` (a gitignore-style pattern matched on `working_dir`) in + your own user-facing message, and offer to guide them through applying it. You never + edit the file yourself. **Not about sub-sessions or delete scope** — deletion always + removes the whole session graph regardless of this setting. Applying it has two + effects, and your message must state both: persisting the pattern to `settings.yaml` + makes the exclude filter correct in a **new session** too, and running + `set_ingestion_filters` in the **root session** makes it take effect in the + already-running session (and any sub-session spawned afterward) with no restart. That + tool lives in the root session, not in this delegated agent — if you can't reach it, + ask the user or the root session to run it. +4. **Impact.** State it (see "Impact + permanence"). +5. **Confirm.** Explicit, naming the id and server(s). +6. **Delete and verify on every server** (all-servers completeness). + +## Find a session by description, then delete + +1. **Search + narrate.** If the user describes the session by topic, content, date, or + any other non-trivial criteria ("the session about X", "sessions that discussed Y", + "my session from last week about Z"), delegate to `graph-analyst`. It runs the + search and returns, for each candidate, both the key facts and a short synthesized + overview of what the session was about (built from that session's root prompts, + never subsessions) — you present those overviews directly; you never build the + narrative yourself and never query the graph. The overview must be a synthesis, + never a raw or verbatim prompt quote — if `graph-analyst` can't produce one, use + "not available." This is a data-fetch delegation, not a hand-off of the + conversation — the results come back to you and you keep driving the flow. Skip the + delegation only for a trivial direct lookup — the user names an exact session id, or + means the current session — and call `session_summary` on it directly instead. +2. **Present** the candidate details block(s) to the user directly; the user picks one. +3. **Ownership** — run the ownership check. +4. **Preview → confirm → delete and verify on every server.** + +## Clean up everything pushed from this working directory (yours, from here) + +This is a find-by-criteria cleanup, not a current-session delete. The current-session +delete's defining trait is the CURRENT session resolved from runtime; this cleanup +finds sessions by CRITERIA — `working_dir` = this folder AND `created_by` = you — the +same shape as the find-by-description delete. The only thing it borrows from the +current-session delete is the folder-exclusion offer, because it's your own folder +being pushed. Trigger: the user is worried that data from sessions in THIS folder was +pushed and should not have been — "I think I uploaded data from sessions in this +folder, I want to delete it," "things from this working directory should never have +been pushed." + +1. **Apply the folder exclusion first**, before finding or deleting anything. + Resolve the working directory from the `Working directory` field in your status + context, the same way the current-session delete resolves `Session ID`. Offer the + setting (the same current-session exclusion offer described above), guide the + user through applying it, and confirm it's applied before moving on — while the + folder is still in scope, continued ingestion would keep re-creating the data you + are about to delete. +2. **Run the S2 search, by criteria (this folder + mine).** Delegate to + `graph-analyst` to find every ROOT session (never subsessions) where `working_dir` + matches AND `created_by` is you, across every configured server (all-servers + completeness applies to the search too) — it returns each candidate with a short + synthesized overview alongside the facts, same as the find-by-description delete. +3. **Propose the list.** Present each found root session to the user directly as a + session-details block (using the overview `graph-analyst` returned, per the + find-by-description delete's rule), and build a todo list — one item per session — + so every one is tracked and none is silently missed. +4. **Delete all.** Walk the todo list. For each session, run the normal + preview → impact → explicit confirm → delete → verify on every server it is on + (all-servers completeness). The ownership check still applies per session — a + session in this folder not created by the user still gets the not-yours warning. + Mark each todo item done only once its delete is verified. + +## Ownership check (before deleting any found or named session) + +Call `whoami` for the session's server and compare `contributor_id` to `created_by`: + +- **Different** → tell the user directly, in your own visible message, that it is not + theirs, and wait for a second explicit confirmation before deleting. +- **Same** → their own session; no warning, proceed normally. +- **Null `contributor_id`** → you cannot confirm ownership; ask the user directly and + wait for their answer rather than assume. + +--- + +@foundation:context/shared/common-agent-base.md diff --git a/agents/session-navigator.md b/agents/session-navigator.md index 102d204a..b3bfac8f 100644 --- a/agents/session-navigator.md +++ b/agents/session-navigator.md @@ -6,19 +6,17 @@ bundle: meta: name: session-navigator description: | - MUST NOT be invoked directly by external callers. ALWAYS delegated to by graph-analyst when the graph server is unreachable or returns 0 sessions. - - Local fallback agent for navigating session data via flat JSONL files using bash/jq/grep safe extraction patterns. Handles session discovery, event search, and session navigation under the root resolved from `CONTEXT_INTELLIGENCE_ROOT="${AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH:-$HOME/.amplifier/projects}"` when the context-intelligence graph server is unavailable. - - This agent is NOT called directly by external callers. It is only delegated to by graph-analyst when the graph server is unreachable or returns 0 sessions. External callers should use graph-analyst instead. - - All operations use safe bash/jq/grep patterns that avoid loading 100k+ token events.jsonl lines into context. Never uses graph_query or blob_read — operates entirely on local filesystem files. - - - Context: Graph analyst delegating because server is unreachable - user: [graph-analyst delegates] 'Find tool errors in session abc123 — graph server is unreachable. Workspace: my-project' - assistant: 'I will scope search to workspace my-project. I will first resolve CONTEXT_INTELLIGENCE_ROOT="${AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH:-$HOME/.amplifier/projects}", then look in "$CONTEXT_INTELLIGENCE_ROOT"/my-project/sessions/ first, then filter by workspace field if needed. I will search for tool errors using safe jq extraction patterns.' - + Local fallback for context-intelligence session navigation — session + discovery, event search, and JSONL traversal via safe bash/jq/grep + patterns that never load 100k+ token events.jsonl lines into context. + Operates entirely on the local filesystem under + ${AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH:-$HOME/.amplifier/projects}; + never uses graph_query or blob_read. + + Reached only via graph-analyst, which delegates here when the graph + server is unreachable or the workspace returns 0 sessions. If you are + an external caller deciding where to send a session-analysis task, + send it to graph-analyst instead — it picks between the two. model_role: general diff --git a/behaviors/context-intelligence-analysis.yaml b/behaviors/context-intelligence-analysis.yaml index 4161ad8d..18e685c1 100644 --- a/behaviors/context-intelligence-analysis.yaml +++ b/behaviors/context-intelligence-analysis.yaml @@ -2,24 +2,23 @@ bundle: name: context-intelligence-analysis-behavior version: 0.1.0 description: > - LAYER 2 of 3. Adds graph-analyst + graph skills (blob-reading, graph-query, - session-reconstruction, workflow-pattern-analysis). Includes - context-intelligence-navigation. Use for graph read/query/exploration - without the design mode. + Imports the navigation behavior for the three context-intelligence agents + (graph-analyst, session-navigator, server-data-ops) instead of listing them + again, and adds the graph skills (blob-reading, graph-query, + session-reconstruction, workflow-pattern-analysis). Use for graph + read/query/exploration and session data operations without the design mode. includes: + # The three agents (and tool-delegate + the session-navigation skill) come from + # here; this behavior only adds the graph skills below, which list-merge with + # the navigation layer's skill list. - bundle: context-intelligence:behaviors/context-intelligence-navigation -agents: - include: - - context-intelligence:graph-analyst - tools: - module: tool-skills source: git+https://github.com/microsoft/amplifier-bundle-skills@main#subdirectory=modules/tool-skills config: skills: - # Concatenates with the navigation layer's skill list (list-merge with dedup). - "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills/blob-reading" - "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills/context-intelligence-graph-query" - "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills/context-intelligence-session-reconstruction" diff --git a/behaviors/context-intelligence-logging.yaml b/behaviors/context-intelligence-logging.yaml index 3c53de84..bbd22208 100644 --- a/behaviors/context-intelligence-logging.yaml +++ b/behaviors/context-intelligence-logging.yaml @@ -104,3 +104,10 @@ hooks: # url: "${TEAM_CI_URL:}" # api_key: "${TEAM_CI_KEY:}" # include: ["**/client-x/"] # the client-x project dir and everything under it + +# Live-set-filters: expose the ingestion hook's set_ingestion_filters capability +# as a root-session tool so the running root agent can make a destination +# `exclude` edit take effect immediately, without a restart. +tools: + - module: tool-context-intelligence-set-filters + source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/tool-context-intelligence-set-filters diff --git a/behaviors/context-intelligence-navigation.yaml b/behaviors/context-intelligence-navigation.yaml index 65592b5d..d0e57d73 100644 --- a/behaviors/context-intelligence-navigation.yaml +++ b/behaviors/context-intelligence-navigation.yaml @@ -2,13 +2,17 @@ bundle: name: context-intelligence-navigation-behavior version: 0.1.0 description: > - LAYER 1 of 3 (innermost). Adds session-navigator (reads raw session JSONL - on disk, no graph server). Includes: nothing. Use alone for local/offline - navigation fallback. + Carries the three context-intelligence agents (graph-analyst, + session-navigator, server-data-ops) as an independent, equal copy of the + analysis behavior's agent set. Imports nothing. session-navigator reads raw + session JSONL on disk with no graph server; use for local/offline navigation + plus graph analysis and session data operations. agents: include: + - context-intelligence:graph-analyst - context-intelligence:session-navigator + - context-intelligence:server-data-ops tools: - module: tool-delegate diff --git a/bundle.dot b/bundle.dot index a911b904..85622bd3 100644 --- a/bundle.dot +++ b/bundle.dot @@ -1,82 +1,90 @@ +// Context Intelligence: a bundle that records what happens during Amplifier sessions and lets you query that history as a searchable graph. digraph context_intelligence { rankdir=LR fontname="Helvetica" fontsize=12 - label="context-intelligence v0.1.0 — bundle repo" + label="Context Intelligence v0.1.0\nRecords session activity and makes it searchable" labelloc=t labeljust=c nodesep=0.6 ranksep=0.7 bgcolor="white" - source_hash="0758d63959ef34f091dbe8fc6e27bc712f154503c829bb35474423dd213c2c27" + source_hash="3fb54cded848d61b162e4fffa3c7445239361e2ed7d48abca8c1814a0fe49afd" node [fontname="Helvetica", fontsize=11, style="filled,rounded"] edge [fontname="Helvetica", fontsize=9] - root_context_intelligence [label="context-intelligence v0.1.0\n0 tools · 0 agents\n~98 tok aggregate", shape=box, fillcolor="#80cbc4", style="filled,rounded,bold", penwidth=2] + root_context_intelligence [label="Context Intelligence (main entry point) v0.1.0\n0 tools · 0 agents\n~98 tok aggregate", shape=box, fillcolor="#80cbc4", style="filled,rounded,bold", penwidth=2] subgraph cluster_behaviors { - label="Behaviors" + label="Capability Packages (features you can switch on)" style="filled" fillcolor="#f9f9f9" color="#999999" - beh_context_intelligence_analysis_behavior [label="context-intelligence-analysis-behavior\n1 tools\n~864 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] - beh_context_intelligence_design_behavior [label="context-intelligence-design-behavior\n1 tools\n~331 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] - beh_context_intelligence_logging_behavior [label="context-intelligence-logging-behavior\n1 tools\n~1656 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] - beh_context_intelligence_navigation_behavior [label="context-intelligence-navigation-behavior\n2 tools\n~617 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] - beh_context_intelligence_behavior [label="context-intelligence-behavior\n~236 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] + beh_context_intelligence_analysis_behavior [label="Analyze Past Sessions\n1 tools\n~974 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] + beh_context_intelligence_design_behavior [label="Design New Query Tools\n1 tools\n~331 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] + beh_context_intelligence_logging_behavior [label="Record Session Activity\n1 tools\n~1656 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] + beh_context_intelligence_navigation_behavior [label="Browse & Search History\n2 tools\n~878 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] + beh_context_intelligence_behavior [label="Core Bundle Wiring\n~236 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] } subgraph cluster_agents { - label="Agents" + label="Specialist Assistants (each handles one kind of question)" style="filled" fillcolor="#f9f9f9" color="#999999" - agt_context_intelligence_design_facilitator [label="context-intelligence-design-facilitator\n~187 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] - agt_context_intelligence_tool_designer [label="context-intelligence-tool-designer\n~198 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] - agt_graph_analyst [label="graph-analyst\n~543 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] - agt_session_navigator [label="session-navigator\n~422 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] + agt_context_intelligence_design_facilitator [label="Design Conversation Guide\n~187 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] + agt_context_intelligence_tool_designer [label="Query Tool Builder\n~198 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] + agt_graph_analyst [label="History Graph Analyst\n~191 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] + agt_server_data_ops [label="Stored Data Caretaker\n~270 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] + agt_session_navigator [label="Local Session Browser\n~157 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] } subgraph cluster_modules { - label="Modules" + label="Building Blocks (the code that does the work)" style="filled" fillcolor="#f9f9f9" color="#999999" - mod_hook_context_intelligence [label="hook-context-intelligence", shape=box, fillcolor="#bbdefb", style="filled,rounded"] - mod_tool_context_intelligence_query [label="tool-context-intelligence-query", shape=box, fillcolor="#bbdefb", style="filled,rounded"] - mod_tool_context_intelligence_upload [label="tool-context-intelligence-upload", shape=box, fillcolor="#bbdefb", style="filled,rounded"] + mod_hook_context_intelligence [label="Activity Recorder\nhook-context-intelligence", shape=box, fillcolor="#bbdefb", style="filled,rounded"] + mod_hook_server_data_ops_lockdown [label="Data Safety Guard\nhook-server-data-ops-lockdown", shape=box, fillcolor="#bbdefb", style="filled,rounded"] + mod_tool_context_intelligence_query [label="History Search\ntool-context-intelligence-query", shape=box, fillcolor="#bbdefb", style="filled,rounded"] + mod_tool_context_intelligence_upload [label="History Upload\ntool-context-intelligence-upload", shape=box, fillcolor="#bbdefb", style="filled,rounded"] + mod_tool_server_data_ops [label="Stored Data Management\ntool-server-data-ops", shape=box, fillcolor="#bbdefb", style="filled,rounded"] } subgraph cluster_legend { - label="Legend" + label="Legend — what each colour means" style="filled" fillcolor="white" color="#cccccc" fontsize=9 - leg_root [label="root bundle", shape=box, fillcolor="#80cbc4", style="filled,rounded,bold", fontsize=9] - leg_behavior [label="behavior", shape=box, fillcolor="#e0f2f1", style="filled,rounded", fontsize=9] - leg_agent [label="agent", shape=box, fillcolor="#c8e6c9", style="filled,rounded", fontsize=9] - leg_module [label="module", shape=box, fillcolor="#bbdefb", style="filled,rounded", fontsize=9] - leg_provider [label="provider", shape=box, fillcolor="#e0e0e0", style="filled,rounded", fontsize=9] - leg_context [label="context", shape=box, fillcolor="#e1bee7", style="filled,rounded", fontsize=9] - leg_standalone [label="standalone", shape=box, fillcolor="#80cbc4", style="filled,rounded", fontsize=9] - leg_experiment [label="experiment", shape=box, fillcolor="#e1bee7", style="filled,rounded", fontsize=9] - leg_ext_cost [label="ext+cost", shape=box, fillcolor="#80cbc4", style="dashed", color="red", penwidth=2, fontsize=9] - leg_ext_muted [label="ext+no-cost", shape=box, fillcolor="#f5f5f5", style="dashed", fontsize=9] + leg_root [label="main entry point", shape=box, fillcolor="#80cbc4", style="filled,rounded,bold", fontsize=9] + leg_behavior [label="capability package", shape=box, fillcolor="#e0f2f1", style="filled,rounded", fontsize=9] + leg_agent [label="specialist assistant", shape=box, fillcolor="#c8e6c9", style="filled,rounded", fontsize=9] + leg_module [label="building block", shape=box, fillcolor="#bbdefb", style="filled,rounded", fontsize=9] + leg_provider [label="AI model connection", shape=box, fillcolor="#e0e0e0", style="filled,rounded", fontsize=9] + leg_context [label="reference text", shape=box, fillcolor="#e1bee7", style="filled,rounded", fontsize=9] + leg_standalone [label="ready-to-run setup", shape=box, fillcolor="#80cbc4", style="filled,rounded", fontsize=9] + leg_experiment [label="experimental", shape=box, fillcolor="#e1bee7", style="filled,rounded", fontsize=9] + leg_ext_cost [label="from another repo\n(adds hidden cost)", shape=box, fillcolor="#80cbc4", style="dashed", color="red", penwidth=2, fontsize=9] + leg_ext_muted [label="from another repo\n(no added cost)", shape=box, fillcolor="#f5f5f5", style="dashed", fontsize=9] } disclaimer [label="Token estimates: ~4 chars/token\nSolid border = local (counted)\nDashed + red = external, hidden cost (not counted)\nDashed + muted = external, no cost\nExcludes: sub-session costs, runtime-dynamic", shape=note, fillcolor="#eceff1", style="filled", fontsize=9] - ext_githttps___github_com_microsoft_amplifier_foundation_main [label="amplifier-foundation\n(external, cost)", shape=box, fillcolor="#80cbc4", style="dashed", color="red", penwidth=2] + ext_githttps___github_com_microsoft_amplifier_foundation_main [label="Shared Amplifier Base\namplifier-foundation\n(external, cost)", shape=box, fillcolor="#80cbc4", style="dashed", color="red", penwidth=2] root_context_intelligence -> ext_githttps___github_com_microsoft_amplifier_foundation_main [style=dashed] root_context_intelligence -> beh_context_intelligence_behavior [label="composes"] beh_context_intelligence_analysis_behavior -> agt_graph_analyst [label="owns"] + beh_context_intelligence_analysis_behavior -> agt_session_navigator [label="owns"] + beh_context_intelligence_analysis_behavior -> agt_server_data_ops [label="owns"] beh_context_intelligence_logging_behavior -> mod_hook_context_intelligence [label="uses", penwidth=0.8] + beh_context_intelligence_navigation_behavior -> agt_graph_analyst [label="owns"] beh_context_intelligence_navigation_behavior -> agt_session_navigator [label="owns"] + beh_context_intelligence_navigation_behavior -> agt_server_data_ops [label="owns"] } \ No newline at end of file diff --git a/bundle.png b/bundle.png index d68d2ff8..41833f62 100644 Binary files a/bundle.png and b/bundle.png differ diff --git a/context_intelligence/client.py b/context_intelligence/client.py index 338d4518..ad7dfe80 100644 --- a/context_intelligence/client.py +++ b/context_intelligence/client.py @@ -65,12 +65,21 @@ def __init__( error_type: str, url: str, status_code: int | None = None, + retry_after: int | None = None, ) -> None: super().__init__(message) - #: One of "connection_error" | "timeout" | "http_status" | "decode_error". + #: One of "connection_error" | "timeout" | "http_status" | "decode_error" + #: | "auth_error". "auth_error" -- an unusable credential -- is raised + #: BEFORE the request is attempted, so it is never confused with + #: "decode_error" (a bad response body from a server actually reached). self.error_type = error_type self.url = url self.status_code = status_code + #: Seconds to wait before retrying, parsed from the ``Retry-After`` + #: response header. Set only on a retryable status (a 409 for a delete + #: refused because the session graph is still draining). ``None`` when + #: the server sent no such hint -- the failure is not retryable. + self.retry_after = retry_after # --------------------------------------------------------------------------- @@ -171,7 +180,9 @@ def _http_get_strict(url: str, headers: dict[str, str]) -> Any: CIClientError error_type one of: ``connection_error`` (refused/DNS/reset), ``timeout``, ``http_status`` (non-2xx; ``status_code`` set), or - ``decode_error`` (body is not valid JSON). + ``decode_error`` (body is not valid JSON). ``auth_error`` is + classified by the caller before ``headers`` reaches this function, + so it never appears here. """ if _requests is not None: try: @@ -181,12 +192,14 @@ def _http_get_strict(url: str, headers: dict[str, str]) -> Any: except _requests.exceptions.Timeout as exc: raise CIClientError(f"timeout listing {url}", error_type="timeout", url=url) from exc except _requests.exceptions.HTTPError as exc: - status = getattr(getattr(exc, "response", None), "status_code", None) + _resp = getattr(exc, "response", None) + status = getattr(_resp, "status_code", None) raise CIClientError( f"HTTP {status} from {url}", error_type="http_status", url=url, status_code=status, + retry_after=_retry_after_seconds(getattr(_resp, "headers", None)), ) from exc except (ValueError, json.JSONDecodeError) as exc: # resp.json() failed raise CIClientError( @@ -211,6 +224,7 @@ def _http_get_strict(url: str, headers: dict[str, str]) -> Any: error_type="http_status", url=url, status_code=exc.response.status_code, + retry_after=_retry_after_seconds(exc.response.headers), ) from exc except (ValueError, json.JSONDecodeError) as exc: # resp.json() failed raise CIClientError( @@ -232,6 +246,7 @@ def _http_get_strict(url: str, headers: dict[str, str]) -> Any: error_type="http_status", url=url, status_code=exc.code, + retry_after=_retry_after_seconds(getattr(exc, "headers", None)), ) from exc except (TimeoutError, socket.timeout) as exc: # read timeout raise CIClientError(f"timeout listing {url}", error_type="timeout", url=url) from exc @@ -250,6 +265,127 @@ def _http_get_strict(url: str, headers: dict[str, str]) -> Any: ) from exc +def _retry_after_seconds(response_headers: Any) -> int | None: + """Parse a non-negative integer ``Retry-After`` from response headers. + + Accepts any headers-like object with ``.get`` (requests/httpx/urllib all + provide one). Returns ``None`` when the header is absent or not a plain + delta-seconds integer (the HTTP-date form is not used by this server). + """ + if response_headers is None: + return None + raw = response_headers.get("Retry-After") + if raw is None: + return None + try: + value = int(str(raw).strip()) + except (TypeError, ValueError): + return None + return value if value >= 0 else None + + +def _http_delete_strict(url: str, headers: dict[str, str]) -> Any: + """DELETE *url* with *headers*, classifying-and-RAISING ``CIClientError`` on failure. + + Same shape as ``_http_get_strict`` (see its docstring for the full rule set), + just using the DELETE HTTP verb instead of GET. Used by + ``CIClient.delete_session()`` so a down / slow / rejecting server can never + masquerade as a completed delete. + + Library preference mirrors ``_http_get_strict`` (requests -> httpx -> + urllib.request). Returns the parsed JSON body on a 2xx response. + + Raises + ------ + CIClientError + error_type one of: ``connection_error`` (refused/DNS/reset), + ``timeout``, ``http_status`` (non-2xx; ``status_code`` set -- this is + how a 404 "unknown session" or a 409 "still receiving data / ambiguous + id" reaches the caller), or ``decode_error`` (body is not valid JSON). + ``auth_error`` is classified by the caller before ``headers`` reaches + this function, so it never appears here. + """ + if _requests is not None: + try: + resp = _requests.delete(url, headers=headers, timeout=30) + resp.raise_for_status() + return resp.json() + except _requests.exceptions.Timeout as exc: + raise CIClientError(f"timeout deleting {url}", error_type="timeout", url=url) from exc + except _requests.exceptions.HTTPError as exc: + _resp = getattr(exc, "response", None) + status = getattr(_resp, "status_code", None) + raise CIClientError( + f"HTTP {status} from {url}", + error_type="http_status", + url=url, + status_code=status, + retry_after=_retry_after_seconds(getattr(_resp, "headers", None)), + ) from exc + except (ValueError, json.JSONDecodeError) as exc: # resp.json() failed + raise CIClientError( + f"malformed JSON from {url}", error_type="decode_error", url=url + ) from exc + except _requests.exceptions.RequestException as exc: # ConnectionError, etc. + raise CIClientError( + f"connection error to {url}: {exc}", error_type="connection_error", url=url + ) from exc + + if _httpx is not None: + try: + with _httpx.Client(timeout=30) as client: + resp = client.delete(url, headers=headers) + resp.raise_for_status() + return resp.json() + except _httpx.TimeoutException as exc: + raise CIClientError(f"timeout deleting {url}", error_type="timeout", url=url) from exc + except _httpx.HTTPStatusError as exc: + raise CIClientError( + f"HTTP {exc.response.status_code} from {url}", + error_type="http_status", + url=url, + status_code=exc.response.status_code, + retry_after=_retry_after_seconds(exc.response.headers), + ) from exc + except (ValueError, json.JSONDecodeError) as exc: # resp.json() failed + raise CIClientError( + f"malformed JSON from {url}", error_type="decode_error", url=url + ) from exc + except _httpx.HTTPError as exc: # ConnectError, transport, etc. + raise CIClientError( + f"connection error to {url}: {exc}", error_type="connection_error", url=url + ) from exc + + # stdlib fallback + try: + req = urllib.request.Request(url, headers=headers, method="DELETE") + with urllib.request.urlopen(req, timeout=30) as resp: + raw = resp.read().decode("utf-8") + except urllib.error.HTTPError as exc: # subclass of URLError -- catch FIRST + raise CIClientError( + f"HTTP {exc.code} from {url}", + error_type="http_status", + url=url, + status_code=exc.code, + retry_after=_retry_after_seconds(getattr(exc, "headers", None)), + ) from exc + except (TimeoutError, socket.timeout) as exc: # read timeout + raise CIClientError(f"timeout deleting {url}", error_type="timeout", url=url) from exc + except urllib.error.URLError as exc: + # A URLError may wrap a socket timeout in .reason -- classify that as timeout. + if isinstance(exc.reason, (TimeoutError, socket.timeout)): + raise CIClientError(f"timeout deleting {url}", error_type="timeout", url=url) from exc + raise CIClientError( + f"connection error to {url}: {exc}", error_type="connection_error", url=url + ) from exc + try: + return json.loads(raw) + except (ValueError, json.JSONDecodeError) as exc: + raise CIClientError( + f"malformed JSON from {url}", error_type="decode_error", url=url + ) from exc + + # --------------------------------------------------------------------------- # Safe JSON parse # --------------------------------------------------------------------------- @@ -414,9 +550,28 @@ def __init__( # Internal helpers # ------------------------------------------------------------------ - def _auth_headers(self) -> dict[str, str]: - """Return the ``Authorization`` header dict, computed per-request via strategy.""" - return self._strategy.headers() + def _auth_headers(self, url: str) -> dict[str, str]: + """Return the ``Authorization`` header dict, computed per-request via strategy. + + Called by every public method BEFORE it enters its request path, so an + unusable credential (empty api_key, the "[REDACTED]" sentinel, or an + unexpanded ${VAR} placeholder) is classified as its own ``auth_error`` + -- it is never sent to the server, and never confused with + ``decode_error`` (a genuine bad-JSON response from a server that was + actually reached). + + Raises + ------ + CIClientError + error_type="auth_error" when ``self._strategy.headers()`` raises + ``ValueError`` (unusable credential). + """ + try: + return self._strategy.headers() + except ValueError as exc: + raise CIClientError( + f"unusable credential for {url}: {exc}", error_type="auth_error", url=url + ) from exc # ------------------------------------------------------------------ # Public API @@ -451,7 +606,8 @@ def cypher( "params": params if params is not None else {}, "workspace": workspace, } - result = _http_post(url, body, self._auth_headers()) + headers = self._auth_headers(url) + result = _http_post(url, body, headers) if result is None: return [] if isinstance(result, list): @@ -487,13 +643,14 @@ def list_blob_keys(self, session_id: str) -> set[str]: Raises ------ CIClientError - The request genuinely failed: connection error/refused, timeout, + The request genuinely failed: an unusable credential (``auth_error``), connection error/refused, timeout, non-2xx HTTP status, or a malformed (non-JSON) body. A down / slow / rejecting server can never masquerade as "no blobs" -- see error_type for the classification. """ url = f"{self._server_url}/blobs/{session_id}" - result = _http_get_strict(url, self._auth_headers()) + headers = self._auth_headers(url) + result = _http_get_strict(url, headers) return _parse_blob_keys(result) def fetch_blob(self, session_id: str, key: str) -> Any | None: @@ -515,7 +672,97 @@ def fetch_blob(self, session_id: str, key: str) -> Any | None: Parsed JSON content, or ``None`` when the request fails. """ url = f"{self._server_url}/blobs/{session_id}/{key}" - return _http_get(url, self._auth_headers()) + headers = self._auth_headers(url) + return _http_get(url, headers) + + def session_summary(self, session_id: str) -> dict[str, Any]: + """Fetch the preview facts for a session (read, no changes made). + + Calls ``GET /sessions/{session_id}/summary`` and returns the parsed + JSON dict: the facts about the whole session graph -- who created it, + how many nodes/edges/blobs it has, when it started and last changed, + whether it is safe to delete, and so on. + + Parameters + ---------- + session_id: + The session to look up. + + Returns + ------- + dict + The parsed summary facts. + + Raises + ------ + CIClientError + The request genuinely failed: an unusable credential (``auth_error``), connection error/refused, timeout, + non-2xx HTTP status, or a malformed (non-JSON) body. A 404 means + the session id is not known to the server; a 409 means the + session is still receiving data or the id is ambiguous across + workspaces -- ``status_code`` carries the exact number so the + caller can give a clear message. + """ + url = f"{self._server_url}/sessions/{session_id}/summary" + headers = self._auth_headers(url) + return _http_get_strict(url, headers) + + def delete_session(self, session_id: str) -> dict[str, Any]: + """Delete a session's whole graph from the server (a real, permanent change). + + Calls ``DELETE /sessions/{session_id}``. There is no workspace input and + no "preview only" flag -- the server resolves the session by id and this + always performs the delete. Returns the parsed JSON dict with the result + counts (how many nodes/relationships/blobs were removed, and so on). + + Parameters + ---------- + session_id: + The session to delete. + + Returns + ------- + dict + The parsed result counts. + + Raises + ------ + CIClientError + The request genuinely failed: an unusable credential (``auth_error``), connection error/refused, timeout, + non-2xx HTTP status, or a malformed (non-JSON) body. A 404 means + the session id is not known to the server; a 409 means the + session is still receiving data (not safe to delete yet) or the + id is ambiguous across workspaces -- ``status_code`` carries the + exact number so the caller can give a clear message. + """ + url = f"{self._server_url}/sessions/{session_id}" + headers = self._auth_headers(url) + return _http_delete_strict(url, headers) + + def whoami(self) -> dict[str, Any]: + """Resolve the authenticated caller's identity from the server. + + Calls ``GET /whoami`` and returns the parsed JSON dict: the server's + view of who is making the request right now (e.g. + ``{"contributor_id": ""}``). ``contributor_id`` is ``None`` + when auth is disabled server-side. + + Returns + ------- + dict + The parsed identity dict. + + Raises + ------ + CIClientError + The request genuinely failed: an unusable credential (``auth_error``), connection error/refused, timeout, + non-2xx HTTP status, or a malformed (non-JSON) body. + ``status_code`` carries the exact number so the caller can give a + clear message. + """ + url = f"{self._server_url}/whoami" + headers = self._auth_headers(url) + return _http_get_strict(url, headers) def health_check(self) -> dict[str, Any]: """Check server health by running a simple count query. @@ -586,6 +833,31 @@ def __init__( ) self._timeout: float = timeout + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _auth_headers(self, url: str) -> dict[str, str]: + """Return the ``Authorization`` header dict, computed per-request via strategy. + + Called BEFORE entering a method's request ``try:`` block -- an + unusable credential is classified as ``auth_error`` here rather than + falling into the request's own ``except (ValueError, json.JSONDecodeError)`` + and being misreported as a decode failure. + + Raises + ------ + CIClientError + error_type="auth_error" when ``self._strategy.headers()`` raises + ``ValueError`` (unusable credential). + """ + try: + return self._strategy.headers() + except ValueError as exc: + raise CIClientError( + f"unusable credential for {url}: {exc}", error_type="auth_error", url=url + ) from exc + # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ @@ -619,7 +891,7 @@ async def cypher( Raises ------ CIClientError - The request genuinely failed: connection error/refused, timeout, + The request genuinely failed: an unusable credential (``auth_error``), connection error/refused, timeout, non-2xx HTTP status, or a malformed (non-JSON) response body. A down, slow, or rejecting SELECTED source can never masquerade as an empty success -- see error_type for the classification. @@ -630,9 +902,10 @@ async def cypher( "params": params if params is not None else {}, "workspace": workspace, } + headers = self._auth_headers(url) try: async with httpx.AsyncClient(timeout=self._timeout) as client: # type: ignore[union-attr] - resp = await client.post(url, json=body, headers=self._strategy.headers()) + resp = await client.post(url, json=body, headers=headers) resp.raise_for_status() result = resp.json() except httpx.TimeoutException as exc: # type: ignore[union-attr] @@ -687,13 +960,14 @@ async def fetch_blob(self, session_id: str, key: str) -> Any | None: Raises ------ CIClientError - The request genuinely failed: connection error/refused, timeout, + The request genuinely failed: an unusable credential (``auth_error``), connection error/refused, timeout, non-2xx HTTP status, or a malformed (non-JSON) response body. """ url = f"{self._server_url}/blobs/{session_id}/{key}" + headers = self._auth_headers(url) try: async with httpx.AsyncClient(timeout=self._timeout) as client: # type: ignore[union-attr] - resp = await client.get(url, headers=self._strategy.headers()) + resp = await client.get(url, headers=headers) resp.raise_for_status() return resp.json() except httpx.TimeoutException as exc: # type: ignore[union-attr] @@ -741,16 +1015,17 @@ async def list_blob_keys(self, session_id: str) -> set[str]: Raises ------ CIClientError - The request genuinely failed: connection error/refused, timeout, + The request genuinely failed: an unusable credential (``auth_error``), connection error/refused, timeout, non-2xx HTTP status, or a malformed (non-JSON) body. A down / slow / rejecting server can never masquerade as "no blobs" -- see error_type for the classification. Honors ``self._timeout`` like ``cypher()`` / ``fetch_blob()``. """ url = f"{self._server_url}/blobs/{session_id}" + headers = self._auth_headers(url) try: async with httpx.AsyncClient(timeout=self._timeout) as client: # type: ignore[union-attr] - resp = await client.get(url, headers=self._strategy.headers()) + resp = await client.get(url, headers=headers) resp.raise_for_status() result = resp.json() except httpx.TimeoutException as exc: # type: ignore[union-attr] @@ -773,6 +1048,158 @@ async def list_blob_keys(self, session_id: str) -> set[str]: return _parse_blob_keys(result) + async def session_summary(self, session_id: str) -> dict[str, Any]: + """Fetch the preview facts for a session (read, no changes made; async). + + Calls ``GET /sessions/{session_id}/summary`` and returns the parsed + JSON dict: the facts about the whole session graph -- who created it, + how many nodes/edges/blobs it has, when it started and last changed, + whether it is safe to delete, and so on. + + Parameters + ---------- + session_id: + The session to look up. + + Returns + ------- + dict + The parsed summary facts. + + Raises + ------ + CIClientError + The request genuinely failed: an unusable credential (``auth_error``), connection error/refused, timeout, + non-2xx HTTP status, or a malformed (non-JSON) body. A 404 means + the session id is not known to the server; a 409 means the + session is still receiving data or the id is ambiguous across + workspaces -- ``status_code`` carries the exact number so the + caller can give a clear message. + """ + url = f"{self._server_url}/sessions/{session_id}/summary" + headers = self._auth_headers(url) + try: + async with httpx.AsyncClient(timeout=self._timeout) as client: # type: ignore[union-attr] + resp = await client.get(url, headers=headers) + resp.raise_for_status() + return resp.json() + except httpx.TimeoutException as exc: # type: ignore[union-attr] + raise CIClientError(f"timeout fetching {url}", error_type="timeout", url=url) from exc + except httpx.HTTPStatusError as exc: # type: ignore[union-attr] + raise CIClientError( + f"HTTP {exc.response.status_code} from {url}", + error_type="http_status", + url=url, + status_code=exc.response.status_code, + ) from exc + except (ValueError, json.JSONDecodeError) as exc: # resp.json() failed + raise CIClientError( + f"malformed JSON from {url}", error_type="decode_error", url=url + ) from exc + except httpx.HTTPError as exc: # type: ignore[union-attr] # ConnectError, transport, etc. + raise CIClientError( + f"connection error to {url}: {exc}", error_type="connection_error", url=url + ) from exc + + async def delete_session(self, session_id: str) -> dict[str, Any]: + """Delete a session's whole graph from the server (a real, permanent change; async). + + Calls ``DELETE /sessions/{session_id}``. There is no workspace input and + no "preview only" flag -- the server resolves the session by id and this + always performs the delete. Returns the parsed JSON dict with the result + counts (how many nodes/relationships/blobs were removed, and so on). + + Parameters + ---------- + session_id: + The session to delete. + + Returns + ------- + dict + The parsed result counts. + + Raises + ------ + CIClientError + The request genuinely failed: an unusable credential (``auth_error``), connection error/refused, timeout, + non-2xx HTTP status, or a malformed (non-JSON) body. A 404 means + the session id is not known to the server; a 409 means the + session is still receiving data (not safe to delete yet) or the + id is ambiguous across workspaces -- ``status_code`` carries the + exact number so the caller can give a clear message. + """ + url = f"{self._server_url}/sessions/{session_id}" + headers = self._auth_headers(url) + try: + async with httpx.AsyncClient(timeout=self._timeout) as client: # type: ignore[union-attr] + resp = await client.delete(url, headers=headers) + resp.raise_for_status() + return resp.json() + except httpx.TimeoutException as exc: # type: ignore[union-attr] + raise CIClientError(f"timeout deleting {url}", error_type="timeout", url=url) from exc + except httpx.HTTPStatusError as exc: # type: ignore[union-attr] + raise CIClientError( + f"HTTP {exc.response.status_code} from {url}", + error_type="http_status", + url=url, + status_code=exc.response.status_code, + ) from exc + except (ValueError, json.JSONDecodeError) as exc: # resp.json() failed + raise CIClientError( + f"malformed JSON from {url}", error_type="decode_error", url=url + ) from exc + except httpx.HTTPError as exc: # type: ignore[union-attr] # ConnectError, transport, etc. + raise CIClientError( + f"connection error to {url}: {exc}", error_type="connection_error", url=url + ) from exc + + async def whoami(self) -> dict[str, Any]: + """Resolve the authenticated caller's identity from the server (async). + + Calls ``GET /whoami`` and returns the parsed JSON dict: the server's + view of who is making the request right now (e.g. + ``{"contributor_id": ""}``). ``contributor_id`` is ``None`` + when auth is disabled server-side. + + Returns + ------- + dict + The parsed identity dict. + + Raises + ------ + CIClientError + The request genuinely failed: an unusable credential (``auth_error``), connection error/refused, timeout, + non-2xx HTTP status, or a malformed (non-JSON) body. + ``status_code`` carries the exact number so the caller can give a + clear message. Honors ``self._timeout`` like ``session_summary()``. + """ + url = f"{self._server_url}/whoami" + headers = self._auth_headers(url) + try: + async with httpx.AsyncClient(timeout=self._timeout) as client: # type: ignore[union-attr] + resp = await client.get(url, headers=headers) + resp.raise_for_status() + return resp.json() + except httpx.TimeoutException as exc: # type: ignore[union-attr] + raise CIClientError(f"timeout fetching {url}", error_type="timeout", url=url) from exc + except httpx.HTTPStatusError as exc: # type: ignore[union-attr] + raise CIClientError( + f"HTTP {exc.response.status_code} from {url}", + error_type="http_status", + url=url, + status_code=exc.response.status_code, + ) from exc + except (ValueError, json.JSONDecodeError) as exc: # resp.json() failed + raise CIClientError( + f"malformed JSON from {url}", error_type="decode_error", url=url + ) from exc + except httpx.HTTPError as exc: # type: ignore[union-attr] # ConnectError, transport, etc. + raise CIClientError( + f"connection error to {url}: {exc}", error_type="connection_error", url=url + ) from exc + async def health_check(self) -> dict[str, Any]: """Check server health by running a simple count query (async). diff --git a/context_intelligence/whoami_tool.py b/context_intelligence/whoami_tool.py new file mode 100644 index 00000000..cd34b365 --- /dev/null +++ b/context_intelligence/whoami_tool.py @@ -0,0 +1,177 @@ +"""WhoamiTool -- agent-facing tool that resolves the acting user's identity. + +Lives in the shared ``context_intelligence`` library, not in either tool +module's own package, because it is mounted from two independent modules +(``tool-context-intelligence-query`` and ``tool-server-data-ops``) -- both +import it from here so there is exactly one implementation. Each mounting +module builds its own ``ToolConfigResolver`` and injects it at construction +time; this class never constructs its own resolver. + +Implements the Amplifier Tool protocol. Endpoint selection goes through +``resolve_query_connection`` (see context_intelligence/tool_resolver.py for +the authoritative selection rule); every result carries the resolved +``source``. + +Read-only: resolves who the server thinks is making the request, so a +caller can compare it against a session's ``created_by`` for ownership +warnings. +""" + +from __future__ import annotations + +from typing import Any + +from amplifier_core.models import ToolResult + +from context_intelligence.client import AsyncCIClient, CIClientError +from context_intelligence.tool_resolver import ( + ToolConfigResolver, + _connectable_pool, + _origin_dict, + resolve_query_connection, +) + + +class WhoamiTool: + """Resolve the acting user's identity from the context-intelligence server. + + Implements the Amplifier Tool protocol (name, description, input_schema, + execute). Configuration and provenance are resolved via + resolve_query_connection() at execute() time, over the connectable pool + (tool sources union the hook's upload destinations). + """ + + def __init__(self, coordinator: Any, resolver: ToolConfigResolver | None = None) -> None: + self._coordinator = coordinator + self._tool_resolver = resolver or ToolConfigResolver({}, coordinator) + self._hook_resolver: Any | None = None + + @property + def name(self) -> str: + return "whoami" + + @property + def description(self) -> str: + return ( + "Resolve the authenticated caller's identity for the chosen " + "context-intelligence server -- returns the acting user's " + "`contributor_id` (their github id), or null when auth is " + "disabled server-side. Use this to answer 'who am I' and to " + "compare against a session's `created_by` for ownership " + "warnings before a delete. This makes NO changes -- it is a " + "read. Every result names the `source` (name/url/origin) that " + "answered -- ALWAYS state it in your answer." + ) + + @property + def input_schema(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": ( + "Optional name of a specific connectable endpoint (server) to " + "ask -- either a configured source OR a hook upload destination " + "(the full connectable set; call with list_sources=true to see " + "the names). Omitting `source` uses the default endpoint: the " + "single configured source, or -- if no sources are configured -- " + "the first destination in config order. The only case where " + "omitting it errors is when 2+ SOURCES are configured (then you " + "must pass source=, and the error lists the valid names)." + ), + }, + "list_sources": { + "type": "boolean", + "description": ( + "When true, do NOT resolve an identity. Return the connectable " + "set -- every server this tool can reach, with name, url, and " + "origin (source/destination). Use this to discover valid " + "`source` values before selecting one." + ), + }, + }, + "required": [], + } + + async def execute(self, input: dict[str, Any]) -> ToolResult: + from context_intelligence.tool_resolver import SourceSelectionError + + # Late-mount upgrade: retry hook capability lookup on every call while + # _hook_resolver is None (hook may mount after the tool). + if self._hook_resolver is None: + self._hook_resolver = self._coordinator.get_capability( + "context_intelligence.hook_config_resolver" + ) + + if input.get("list_sources"): + pool = _connectable_pool(self._tool_resolver, self._hook_resolver) + return ToolResult( + success=True, + output={ + "connectable_set": [ + {"name": e.name, "url": e.url, "origin": e.kind} for e in pool.values() + ] + }, + ) + + source_name = input.get("source") + try: + conn = resolve_query_connection( + self._hook_resolver, self._tool_resolver, source_name=source_name + ) + except SourceSelectionError as exc: + return ToolResult( + success=False, + error={ + "message": str(exc), + "type": exc.error_type, # "unknown_source" | "ambiguous_source_selection" + "valid_sources": exc.valid_names, + }, + ) + except ValueError as exc: + # The selected source itself is misconfigured -- names only it. + return ToolResult( + success=False, + error={"message": str(exc), "type": "source_misconfigured"}, + ) + + if not conn.url: + return ToolResult( + success=False, + error={ + "message": "context-intelligence server URL not configured", + "type": "configuration_error", + }, + ) + + async_client = AsyncCIClient( + server_url=conn.url, + api_key=conn.api_key or "", + auth_strategy=conn.auth_strategy, + timeout=self._tool_resolver.request_timeout, + ) + try: + identity = await async_client.whoami() + except CIClientError as exc: + # success=False + output unset is safe: ToolResult.model_post_init + # back-fills output from error["message"] when output is None. Do NOT + # also set output= here or that back-fill is suppressed. + origin_name = conn.origin.name if conn.origin and conn.origin.name else conn.url + message = f"whoami lookup failed against {origin_name}: {exc}" + return ToolResult( + success=False, + error={ + "message": message, + "type": exc.error_type, # connection_error|timeout|http_status|decode_error + "source": _origin_dict(conn.origin), + **({"status_code": exc.status_code} if exc.status_code is not None else {}), + }, + ) + return ToolResult( + success=True, + output={ + "contributor_id": identity.get("contributor_id"), + "source": _origin_dict(conn.origin), + }, + ) diff --git a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py index 070fe9e0..09daa363 100644 --- a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py +++ b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py @@ -74,6 +74,152 @@ async def _discover_events(coordinator: Any) -> set[str]: return discovered +async def apply_active_dispatchers( + coordinator: Any, + resolver: Any, + logging_handler: Any, + destinations: dict[str, Any], +) -> tuple[str, list[str]]: + """Compute active destinations for the live working_dir and install them. + + This is the reusable core of fan-out routing: match_key -> select_active -> + build one dispatcher per active destination -> set_dispatchers (drain-safe + swap). Called ONCE at on_session_ready, and AGAIN by the live-set-filters path + (``context_intelligence.set_ingestion_filters``) after the destinations config + is updated mid-session. ``set_dispatchers`` bounded-closes the previously + installed dispatchers, so calling this repeatedly is safe. + + Returns ``(match_key, sorted_active_names)`` for reporting. + """ + from .config_resolver import Destination + from .fanout import normalize_match_key, select_active + from .handlers.logging_handler import _DestinationDispatcher + + active: dict[str, Destination] = {} + match_key: str = "" + if destinations: + get_cap = getattr(coordinator, "get_capability", None) + working_dir = get_cap("session.working_dir") if get_cap else None + if not working_dir: + log.warning( + "context-intelligence: session.working_dir capability is unavailable; " + "fan-out disabled for this session (local JSONL only)." + ) + else: + match_key = normalize_match_key(working_dir) + active = select_active(destinations, match_key) + + dispatchers = [ + _DestinationDispatcher( + name=d.name, + url=d.url, + api_key=d.api_key, + workspace=resolver.workspace, + working_dir=resolver.working_dir, + dispatch_timeout=resolver.dispatch_timeout, + read_timeout=resolver.dispatch_read_timeout, + connect_timeout=resolver.dispatch_connect_timeout, + failure_threshold=resolver.dispatch_failure_threshold, + queue_capacity=resolver.dispatch_queue_capacity, + close_drain_timeout=resolver.close_drain_timeout, + backoff_initial=resolver.dispatch_backoff_initial, + backoff_max=resolver.dispatch_backoff_max, + backoff_jitter=resolver.dispatch_backoff_jitter, + storage_path=str(resolver.base_path), + forwarding_log_dir=resolver.forwarding_log_dir, + auth_mode=d.auth_mode, + auth_resource=d.auth_resource, + ) + for d in active.values() + ] + await logging_handler.set_dispatchers(dispatchers) + + if not destinations: + log.info("context-intelligence fan-out: no destinations configured — local JSONL only") + elif active: + log.info("context-intelligence fan-out: active -> %s", ", ".join(sorted(active))) + else: + log.warning( + "context-intelligence fan-out: routed to none (local-only) for working_dir=%s", + match_key, + ) + + return match_key, sorted(active) + + +def _read_destinations_from_settings(settings_path: str) -> dict[str, Any]: + """Read the raw ``destinations`` block from a settings.yaml on disk. + + The hook itself never reads settings.yaml (the kernel merges/expands it and + hands mount() a config dict). The set-filters path re-reads the file so an + exclude edit made to it during a session is reflected in the running session. + + Looks first under ``overrides.hook-context-intelligence.config.destinations`` + (the settings.yaml shape), then falls back to a top-level ``destinations:`` + key. Returns {} if neither is + present. Does NOT expand ${VAR}; callers writing on-disk config to set filters + are expected to write already-resolved values (same contract the kernel + applies before mount()). + """ + import yaml + + with open(settings_path) as fh: + doc = yaml.safe_load(fh) or {} + try: + nested = doc["overrides"]["hook-context-intelligence"]["config"]["destinations"] + if isinstance(nested, dict): + return nested + except (KeyError, TypeError): + pass + top = doc.get("destinations") + return top if isinstance(top, dict) else {} + + +def _patch_inherited_hook_config(coordinator: Any, raw_destinations: dict[str, Any]) -> bool: + """Bundle-only: write the new destinations into the in-memory session config + that FUTURE spawned sub-sessions inherit. + + A spawned sub-session copies its parent session's config dict as its + starting point; it does not re-read settings.yaml. That dict is reachable + from the coordinator this hook already holds. Updating this hook's entry in + it is what carries a live filter change to every sub-session spawned + afterward, without touching any module outside this bundle. + + Returns True if a hook-context-intelligence entry was patched. + """ + session = getattr(coordinator, "session", None) + cfg = ( + getattr(session, "config", None) + if session is not None + else getattr(coordinator, "config", None) + ) + if not isinstance(cfg, dict): + return False + hooks = cfg.get("hooks") + if not isinstance(hooks, list): + return False + patched = False + for entry in hooks: + if isinstance(entry, dict) and entry.get("module") == "hook-context-intelligence": + entry.setdefault("config", {})["destinations"] = raw_destinations + patched = True + return patched + + +def _exclude_map(destinations: dict[str, Any]) -> dict[str, list[str]]: + """Normalized {name: sorted(exclude patterns)} for a Destination map.""" + return {name: sorted(dest.exclude) for name, dest in destinations.items()} + + +def _disk_exclude_map(raw: dict[str, Any]) -> dict[str, list[str]]: + """Normalized {name: sorted(exclude patterns)} for a raw on-disk block.""" + out: dict[str, list[str]] = {} + for name, spec in raw.items(): + if isinstance(spec, dict): + out[name] = sorted(spec.get("exclude") or []) + return out + + async def mount( coordinator: Any, config: dict[str, Any] ) -> Callable[[], Coroutine[Any, Any, None]]: @@ -122,6 +268,92 @@ async def mount( } coordinator.register_capability("context_intelligence._hook_state", _hook_state) + async def set_ingestion_filters( + raw_destinations: dict[str, Any] | None = None, + settings_path: str | None = None, + verify_disk: bool = True, + ) -> dict[str, Any]: + """Apply destination routing to this session's live hook, without a restart. + + Source of the new destinations block (exactly one): + - ``settings_path``: re-read the block from a settings.yaml on disk + (the real "user edited settings.yaml" path). + - ``raw_destinations``: an explicit block (used to inject a + live-only patch, e.g. to exercise the live-vs-disk fault check). + + Steps: update the resolver's destinations (cache-invalidated) -> + re-validate -> refresh shared hook state -> rebuild + drain-safe swap the + dispatchers via apply_active_dispatchers. Returns a report of the new + active destinations and their include/exclude. + + When ``verify_disk`` and ``settings_path`` are both given, the resulting + live filter is cross-checked against the on-disk block and a mismatch + raises (fail-loud: the running session must never believe an exclude is + applied when the file disagrees). + """ + disk_raw = _read_destinations_from_settings(settings_path) if settings_path else None + new_raw = raw_destinations if raw_destinations is not None else disk_raw + if new_raw is None: + raise ValueError("set_ingestion_filters: provide raw_destinations or settings_path") + + resolver.update_destinations(new_raw) + new_dests = resolver.validate_destinations() + _hook_state["destinations"] = new_dests + match_key, active = await apply_active_dispatchers( + coordinator, resolver, logging_handler, new_dests + ) + + # Bundle-only propagation to FUTURE sub-sessions: update the session + # config snapshot a spawned sub-session copies from its parent. + inherited_patched = _patch_inherited_hook_config(coordinator, new_raw) + + report: dict[str, Any] = { + "match_key": match_key, + "active": active, + "inherited_snapshot_patched": inherited_patched, + "destinations": { + name: {"include": list(d.include), "exclude": list(d.exclude)} + for name, d in new_dests.items() + }, + "disk_consistent": None, + } + if verify_disk and settings_path is not None: + live = _exclude_map(new_dests) + disk = _disk_exclude_map(_read_destinations_from_settings(settings_path)) + if live != disk: + raise RuntimeError( + "set_ingestion_filters: live filter disagrees with on-disk settings " + f"(live_exclude={live!r} disk_exclude={disk!r}); refusing to leave " + "the running session believing an exclude is applied when the file " + "disagrees." + ) + report["disk_consistent"] = True + return report + + def verify_ingestion_consistency(settings_path: str) -> dict[str, Any]: + """Fail-loud compare of the session's LIVE exclude filter vs on-disk. + + Pure check — mutates nothing. Raises when the running session's live + per-destination exclude set does not match the settings.yaml on disk, in + EITHER direction (live patched but file not written; file written but + session not reapplied). Returns the two maps on agreement. + """ + live = _exclude_map(resolver.validate_destinations()) + disk = _disk_exclude_map(_read_destinations_from_settings(settings_path)) + if live != disk: + raise RuntimeError( + "context-intelligence: live ingestion filter disagrees with on-disk " + f"settings (live_exclude={live!r} disk_exclude={disk!r})." + ) + return {"live_exclude": live, "disk_exclude": disk, "consistent": True} + + coordinator.register_capability( + "context_intelligence.set_ingestion_filters", set_ingestion_filters + ) + coordinator.register_capability( + "context_intelligence.verify_ingestion_consistency", verify_ingestion_consistency + ) + async def cleanup() -> None: try: await logging_handler.close() @@ -156,8 +388,6 @@ async def on_session_ready(coordinator: Any) -> None: and installs per-destination dispatchers into the LoggingHandler. """ from .config_resolver import Destination - from .handlers.logging_handler import _DestinationDispatcher - from .fanout import normalize_match_key, select_active state = coordinator.get_capability("context_intelligence._hook_state") if state is None: @@ -222,64 +452,10 @@ async def on_session_ready(coordinator: Any) -> None: _ENV_VAR, ) - # --- Destination selection (C2: working_dir capability ONLY, fail-loud) --- - active: dict[str, Destination] = {} - match_key: str = "" - if destinations: - get_cap = getattr(coordinator, "get_capability", None) - working_dir = get_cap("session.working_dir") if get_cap else None - if not working_dir: - # working_dir capability unavailable. Do NOT raise here: the kernel - # CATCHES on_session_ready exceptions (Phase 6, _session_init.py) and - # continues the session, so a raise is swallowed AND aborts the rest of - # this callback — silently disabling ALL capture, including the local - # JSONL the design guarantees is always written. Degrade to local-only - # (active = {}) with a discoverable WARNING and fall through so the - # LoggingHandler is still registered below. - log.warning( - "context-intelligence: session.working_dir capability is unavailable; " - "fan-out disabled for this session (local JSONL only)." - ) - else: - match_key = normalize_match_key(str(working_dir)) - active = select_active(destinations, match_key) - - # Build one dispatcher per ACTIVE destination (D9). - dispatchers = [ - _DestinationDispatcher( - name=d.name, - url=d.url, - api_key=d.api_key, - workspace=resolver.workspace, - working_dir=resolver.working_dir, - dispatch_timeout=resolver.dispatch_timeout, - read_timeout=resolver.dispatch_read_timeout, - connect_timeout=resolver.dispatch_connect_timeout, - failure_threshold=resolver.dispatch_failure_threshold, - queue_capacity=resolver.dispatch_queue_capacity, - close_drain_timeout=resolver.close_drain_timeout, - backoff_initial=resolver.dispatch_backoff_initial, - backoff_max=resolver.dispatch_backoff_max, - backoff_jitter=resolver.dispatch_backoff_jitter, - storage_path=str(resolver.base_path), - forwarding_log_dir=resolver.forwarding_log_dir, - auth_mode=d.auth_mode, - auth_resource=d.auth_resource, - ) - for d in active.values() - ] - await logging_handler.set_dispatchers(dispatchers) - - # --- Fan-out log line (S2) --- - if not destinations: - log.info("context-intelligence fan-out: no destinations configured — local JSONL only") - elif active: - log.info("context-intelligence fan-out: active -> %s", ", ".join(sorted(active))) - else: - log.warning( - "context-intelligence fan-out: routed to none (local-only) for working_dir=%s", - match_key, - ) + # --- Destination selection + dispatcher install (C2: working_dir ONLY) --- + # Factored into apply_active_dispatchers so the live-set-filters capability can + # re-run the exact same routing computation mid-session. + await apply_active_dispatchers(coordinator, resolver, logging_handler, destinations) # Step 1: canonical kernel events + all module contributions # _discover_events returns: set(ALL_EVENTS) + collect_contributions diff --git a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py index bcb10dd2..0ea5ada9 100644 --- a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py +++ b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py @@ -579,6 +579,18 @@ def context_intelligence_api_key(self) -> str | None: ) return str(value) if value else None + def update_destinations(self, raw_destinations: dict[str, Any]) -> None: + """Replace the raw ``destinations`` config and invalidate the cache. + + The resolver caches ``destinations`` on first access and never re-reads + it. The live-set-filters path calls this to install a fresh destinations + block mid-session (e.g. reflecting an on-disk settings.yaml exclude edit) + so the next ``.destinations`` / ``validate_destinations()`` access + re-derives from the new dict instead of returning the startup snapshot. + """ + self._config = {**self._config, "destinations": raw_destinations} + self._destinations = None + @property def destinations(self) -> dict[str, Destination]: """Resolved fan-out destinations, keyed by name. diff --git a/modules/hook-context-intelligence/tests/test_set_filters_tool.py b/modules/hook-context-intelligence/tests/test_set_filters_tool.py new file mode 100644 index 00000000..73b3bc6e --- /dev/null +++ b/modules/hook-context-intelligence/tests/test_set_filters_tool.py @@ -0,0 +1,178 @@ +"""Tests for SetIngestionFiltersTool (modules/tool-context-intelligence-set-filters). + +That module has no test infrastructure of its own (no tests/ dir, no lock +file) and its pyproject depends on the *published* amplifier-bundle-context- +intelligence package rather than this local checkout, so it cannot be +installed here without network access. Its own runtime dependency is just +``amplifier_core.models.ToolResult``, which IS available in this module's +venv (amplifier-core is already a dev dependency here) -- so the tool's +source is imported directly by adding its package directory to sys.path for +the duration of these tests (via ``monkeypatch.syspath_prepend``, which +pytest itself reverts on teardown). No source file is modified to make this +work. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +_TOOL_MODULE_DIR = ( + Path(__file__).parent.parent.parent.parent / "modules" / "tool-context-intelligence-set-filters" +) + + +@pytest.fixture +def set_filters_tool_module(monkeypatch: pytest.MonkeyPatch): + """Import amplifier_module_tool_context_intelligence_set_filters from source. + + Skips (rather than fails) if the sibling module directory is not present + at the expected workspace layout -- this test is a bonus cross-module + check, not a load-bearing requirement of the hook module's own test + suite. + """ + if not _TOOL_MODULE_DIR.is_dir(): + pytest.skip(f"tool module directory not found at {_TOOL_MODULE_DIR}") + + monkeypatch.syspath_prepend(str(_TOOL_MODULE_DIR)) + module_name = "amplifier_module_tool_context_intelligence_set_filters" + sys.modules.pop(module_name, None) + import importlib + + module = importlib.import_module(module_name) + yield module + sys.modules.pop(module_name, None) + + +class TestSetIngestionFiltersToolCapabilityPresent: + async def test_execute_returns_success_with_report(self, set_filters_tool_module) -> None: + report = { + "match_key": "[REDACTED:SECRET]", + "active": ["d1"], + "inherited_snapshot_patched": True, + "destinations": {"d1": {"include": ["**"], "exclude": []}}, + "disk_consistent": None, + } + set_filters_capability = AsyncMock(return_value=report) + + coordinator = MagicMock() + coordinator.get_capability = MagicMock( + side_effect=lambda name: ( + set_filters_capability + if name == "context_intelligence.set_ingestion_filters" + else None + ) + ) + + tool = set_filters_tool_module.SetIngestionFiltersTool(coordinator) + result = await tool.execute({}) + + assert result.success is True + assert result.output == report + set_filters_capability.assert_awaited_once() + assert set_filters_capability.await_args is not None + assert set_filters_capability.await_args.kwargs["verify_disk"] is True + + async def test_execute_passes_custom_settings_path(self, set_filters_tool_module) -> None: + set_filters_capability = AsyncMock(return_value={"active": []}) + coordinator = MagicMock() + coordinator.get_capability = MagicMock( + side_effect=lambda name: ( + set_filters_capability + if name == "context_intelligence.set_ingestion_filters" + else None + ) + ) + + tool = set_filters_tool_module.SetIngestionFiltersTool(coordinator) + await tool.execute({"settings_path": "/tmp/custom-settings.yaml"}) + + assert set_filters_capability.await_args is not None + assert ( + set_filters_capability.await_args.kwargs["settings_path"] == "/tmp/custom-settings.yaml" + ) + + async def test_execute_surfaces_capability_exception(self, set_filters_tool_module) -> None: + set_filters_capability = AsyncMock(side_effect=RuntimeError("disk disagrees")) + coordinator = MagicMock() + coordinator.get_capability = MagicMock( + side_effect=lambda name: ( + set_filters_capability + if name == "context_intelligence.set_ingestion_filters" + else None + ) + ) + + tool = set_filters_tool_module.SetIngestionFiltersTool(coordinator) + result = await tool.execute({}) + + assert result.success is False + assert "disk disagrees" in result.output["error"] + + +class TestSetIngestionFiltersToolCapabilityAbsent: + async def test_execute_fails_loud_when_capability_unavailable( + self, set_filters_tool_module + ) -> None: + coordinator = MagicMock() + coordinator.get_capability = MagicMock(return_value=None) + + tool = set_filters_tool_module.SetIngestionFiltersTool(coordinator) + result = await tool.execute({}) + + assert result.success is False + assert "unavailable" in result.output["error"] + assert "context_intelligence.set_ingestion_filters" in result.output["error"] + + +class TestSetIngestionFiltersToolVerifyOnly: + async def test_verify_only_uses_verify_capability(self, set_filters_tool_module) -> None: + verify_result = {"live_exclude": {}, "disk_exclude": {}, "consistent": True} + verify_capability = MagicMock(return_value=verify_result) + coordinator = MagicMock() + coordinator.get_capability = MagicMock( + side_effect=lambda name: ( + verify_capability + if name == "context_intelligence.verify_ingestion_consistency" + else None + ) + ) + + tool = set_filters_tool_module.SetIngestionFiltersTool(coordinator) + result = await tool.execute({"verify_only": True}) + + assert result.success is True + assert result.output == verify_result + verify_capability.assert_called_once() + + async def test_verify_only_fails_loud_when_capability_unavailable( + self, set_filters_tool_module + ) -> None: + coordinator = MagicMock() + coordinator.get_capability = MagicMock(return_value=None) + + tool = set_filters_tool_module.SetIngestionFiltersTool(coordinator) + result = await tool.execute({"verify_only": True}) + + assert result.success is False + assert "unavailable" in result.output["error"] + + async def test_verify_only_surfaces_capability_exception(self, set_filters_tool_module) -> None: + verify_capability = MagicMock(side_effect=RuntimeError("live/disk mismatch")) + coordinator = MagicMock() + coordinator.get_capability = MagicMock( + side_effect=lambda name: ( + verify_capability + if name == "context_intelligence.verify_ingestion_consistency" + else None + ) + ) + + tool = set_filters_tool_module.SetIngestionFiltersTool(coordinator) + result = await tool.execute({"verify_only": True}) + + assert result.success is False + assert "live/disk mismatch" in result.output["error"] diff --git a/modules/hook-context-intelligence/tests/test_set_ingestion_filters.py b/modules/hook-context-intelligence/tests/test_set_ingestion_filters.py new file mode 100644 index 00000000..1af8f040 --- /dev/null +++ b/modules/hook-context-intelligence/tests/test_set_ingestion_filters.py @@ -0,0 +1,365 @@ +"""Tests for the live-set-filters ingestion capability. + +Covers: +- HookConfigResolver.update_destinations() cache invalidation (config_resolver.py) +- context_intelligence.set_ingestion_filters capability: live exclude take-effect, + disk-consistency reporting +- context_intelligence.set_ingestion_filters patching the in-memory session config + snapshot inherited by future sub-sessions (_patch_inherited_hook_config) +- context_intelligence.verify_ingestion_consistency fail-loud in both directions +- _patch_inherited_hook_config as a standalone unit +""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock + +import pytest +import yaml + +from amplifier_module_hook_context_intelligence.config_resolver import HookConfigResolver +from tests.helpers import make_lifecycle_coordinator, mount_and_ready + + +def _bare_resolver_coordinator() -> MagicMock: + """A minimal coordinator double for direct HookConfigResolver construction.""" + coordinator = MagicMock() + coordinator.config = {} + coordinator.get_capability = MagicMock(return_value=None) + return coordinator + + +def _write_settings(path: Path, destinations: dict[str, Any]) -> str: + """Write a settings.yaml in the real overrides.hook-context-intelligence.config shape.""" + doc = { + "overrides": { + "hook-context-intelligence": { + "config": {"destinations": destinations}, + } + } + } + path.write_text(yaml.safe_dump(doc), encoding="utf-8") + return str(path) + + +# --------------------------------------------------------------------------- +# A. update_destinations cache invalidation +# --------------------------------------------------------------------------- +class TestUpdateDestinationsCacheInvalidation: + def test_destinations_property_reflects_new_block_after_update(self) -> None: + coordinator = _bare_resolver_coordinator() + initial = {"d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}} + resolver = HookConfigResolver({"destinations": initial}, coordinator) + + cached = resolver.destinations + assert cached["d1"].exclude == () + + new_raw = { + "d1": { + "url": "http://d1", + "api_key": "k1", + "include": ["**"], + "exclude": ["/tmp/foo/**"], + } + } + resolver.update_destinations(new_raw) + + refreshed = resolver.destinations + assert refreshed["d1"].exclude == ("/tmp/foo/**",) + assert refreshed is not cached, "destinations must be re-derived, not the stale cached dict" + # The old cached reference itself must never mutate in place either. + assert cached["d1"].exclude == () + + def test_validate_destinations_reflects_new_block_after_update(self) -> None: + coordinator = _bare_resolver_coordinator() + initial = {"d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}} + resolver = HookConfigResolver({"destinations": initial}, coordinator) + assert resolver.validate_destinations()["d1"].exclude == () + + new_raw = { + "d1": { + "url": "http://d1", + "api_key": "k1", + "include": ["**"], + "exclude": ["/tmp/foo/**"], + } + } + resolver.update_destinations(new_raw) + + assert resolver.validate_destinations()["d1"].exclude == ("/tmp/foo/**",) + + def test_update_destinations_can_add_a_new_destination(self) -> None: + coordinator = _bare_resolver_coordinator() + resolver = HookConfigResolver({"destinations": {}}, coordinator) + assert resolver.destinations == {} + + resolver.update_destinations( + {"new-dest": {"url": "http://new", "api_key": "k", "include": ["**"], "exclude": []}} + ) + + assert set(resolver.destinations) == {"new-dest"} + + +# --------------------------------------------------------------------------- +# B. set_ingestion_filters excludes a destination live +# --------------------------------------------------------------------------- +class TestSetIngestionFiltersExcludesDestinationLive: + async def test_set_filters_updates_active_destinations(self, tmp_path: Path) -> None: + working_dir = str(tmp_path) + config = { + "destinations": { + "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}, + "d2": {"url": "http://d2", "api_key": "k2", "include": ["**"], "exclude": []}, + } + } + coordinator = make_lifecycle_coordinator(working_dir=working_dir) + cleanup = await mount_and_ready(coordinator, config) + try: + set_filters = coordinator.get_capability("context_intelligence.set_ingestion_filters") + assert set_filters is not None + + new_raw = { + "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": ["**"]}, + "d2": {"url": "http://d2", "api_key": "k2", "include": ["**"], "exclude": []}, + } + report = await set_filters(raw_destinations=new_raw) + + assert "d1" not in report["active"] + assert "d2" in report["active"] + assert report["destinations"]["d1"]["exclude"] == ["**"] + assert report["destinations"]["d2"]["exclude"] == [] + assert report["match_key"] + finally: + await cleanup() + + async def test_disk_consistent_is_none_without_settings_path(self, tmp_path: Path) -> None: + working_dir = str(tmp_path) + config = { + "destinations": { + "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}, + } + } + coordinator = make_lifecycle_coordinator(working_dir=working_dir) + cleanup = await mount_and_ready(coordinator, config) + try: + set_filters = coordinator.get_capability("context_intelligence.set_ingestion_filters") + new_raw = { + "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": ["**"]}, + } + + # Default verify_disk=True but no settings_path given -> the disk + # cross-check never runs; disk_consistent stays None either way. + report = await set_filters(raw_destinations=new_raw) + assert report["disk_consistent"] is None + + report_no_verify = await set_filters(raw_destinations=new_raw, verify_disk=False) + assert report_no_verify["disk_consistent"] is None + finally: + await cleanup() + + async def test_set_filters_requires_a_destinations_source(self, tmp_path: Path) -> None: + working_dir = str(tmp_path) + config = {"destinations": {}} + coordinator = make_lifecycle_coordinator(working_dir=working_dir) + cleanup = await mount_and_ready(coordinator, config) + try: + set_filters = coordinator.get_capability("context_intelligence.set_ingestion_filters") + with pytest.raises(ValueError): + await set_filters() + finally: + await cleanup() + + +# --------------------------------------------------------------------------- +# C. set_ingestion_filters patches the inherited session config snapshot +# --------------------------------------------------------------------------- +class TestSetIngestionFiltersPatchesInheritedSnapshot: + async def test_inherited_hook_config_patched(self, tmp_path: Path) -> None: + working_dir = str(tmp_path) + initial_raw = { + "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}, + } + config = {"destinations": initial_raw} + coordinator = make_lifecycle_coordinator(working_dir=working_dir) + hooks_list = [ + {"module": "hook-context-intelligence", "config": {"destinations": initial_raw}}, + {"module": "other-hook", "config": {"foo": "bar"}}, + ] + coordinator.session = SimpleNamespace(config={"hooks": hooks_list}) + + cleanup = await mount_and_ready(coordinator, config) + try: + set_filters = coordinator.get_capability("context_intelligence.set_ingestion_filters") + new_raw = { + "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": ["**"]}, + } + report = await set_filters(raw_destinations=new_raw) + + assert report["inherited_snapshot_patched"] is True + ci_entry = next(h for h in hooks_list if h["module"] == "hook-context-intelligence") + assert ci_entry["config"]["destinations"] == new_raw + other_entry = next(h for h in hooks_list if h["module"] == "other-hook") + assert other_entry["config"] == {"foo": "bar"} + finally: + await cleanup() + + async def test_inherited_snapshot_not_patched_without_a_real_session_config( + self, tmp_path: Path + ) -> None: + """No .session.config dict wired up -> patch is a no-op, reported honestly.""" + working_dir = str(tmp_path) + config = { + "destinations": { + "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}, + } + } + coordinator = make_lifecycle_coordinator(working_dir=working_dir) + # coordinator.session is left as an auto-vivified MagicMock attribute here + # (not a real dict-backed session config) -> _patch_inherited_hook_config + # must report False rather than silently pretending it patched anything. + cleanup = await mount_and_ready(coordinator, config) + try: + set_filters = coordinator.get_capability("context_intelligence.set_ingestion_filters") + report = await set_filters(raw_destinations=config["destinations"]) + assert report["inherited_snapshot_patched"] is False + finally: + await cleanup() + + +# --------------------------------------------------------------------------- +# D. verify_ingestion_consistency fail-loud both directions +# --------------------------------------------------------------------------- +class TestVerifyIngestionConsistency: + async def test_live_patched_but_disk_not_written_raises(self, tmp_path: Path) -> None: + working_dir = str(tmp_path) + config = { + "destinations": { + "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}, + } + } + coordinator = make_lifecycle_coordinator(working_dir=working_dir) + cleanup = await mount_and_ready(coordinator, config) + try: + resolver = coordinator.get_capability("context_intelligence.hook_config_resolver") + resolver.update_destinations( + { + "d1": { + "url": "http://d1", + "api_key": "k1", + "include": ["**"], + "exclude": ["**"], + } + } + ) + + settings_path = _write_settings( + tmp_path / "settings.yaml", + {"d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}}, + ) + + verify = coordinator.get_capability("context_intelligence.verify_ingestion_consistency") + with pytest.raises(RuntimeError, match="live ingestion filter disagrees"): + verify(settings_path) + finally: + await cleanup() + + async def test_disk_has_exclude_live_does_not_raises(self, tmp_path: Path) -> None: + working_dir = str(tmp_path) + config = { + "destinations": { + "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}, + } + } + coordinator = make_lifecycle_coordinator(working_dir=working_dir) + cleanup = await mount_and_ready(coordinator, config) + try: + settings_path = _write_settings( + tmp_path / "settings.yaml", + {"d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": ["**"]}}, + ) + verify = coordinator.get_capability("context_intelligence.verify_ingestion_consistency") + with pytest.raises(RuntimeError, match="live ingestion filter disagrees"): + verify(settings_path) + finally: + await cleanup() + + async def test_live_and_disk_agree_returns_consistent(self, tmp_path: Path) -> None: + working_dir = str(tmp_path) + config = { + "destinations": { + "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}, + } + } + coordinator = make_lifecycle_coordinator(working_dir=working_dir) + cleanup = await mount_and_ready(coordinator, config) + try: + settings_path = _write_settings( + tmp_path / "settings.yaml", + {"d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}}, + ) + verify = coordinator.get_capability("context_intelligence.verify_ingestion_consistency") + result = verify(settings_path) + + assert result["consistent"] is True + assert result["live_exclude"] == {"d1": []} + assert result["disk_exclude"] == {"d1": []} + finally: + await cleanup() + + +# --------------------------------------------------------------------------- +# E. _patch_inherited_hook_config unit +# --------------------------------------------------------------------------- +class TestPatchInheritedHookConfigUnit: + def test_patches_matching_hook_entry(self) -> None: + from amplifier_module_hook_context_intelligence import _patch_inherited_hook_config + + hooks_list = [ + {"module": "hook-context-intelligence", "config": {"destinations": {"old": {}}}}, + {"module": "other-hook", "config": {"foo": "bar"}}, + ] + coordinator = SimpleNamespace(session=SimpleNamespace(config={"hooks": hooks_list})) + + new_raw = {"d1": {"url": "http://d1", "api_key": "k1"}} + patched = _patch_inherited_hook_config(coordinator, new_raw) + + assert patched is True + ci_entry = next(h for h in hooks_list if h["module"] == "hook-context-intelligence") + assert ci_entry["config"]["destinations"] == new_raw + other_entry = next(h for h in hooks_list if h["module"] == "other-hook") + assert other_entry["config"] == {"foo": "bar"} + + def test_returns_false_when_no_session_or_config(self) -> None: + from amplifier_module_hook_context_intelligence import _patch_inherited_hook_config + + class Empty: + pass + + assert _patch_inherited_hook_config(Empty(), {"a": {}}) is False + + def test_returns_false_when_hooks_not_a_list(self) -> None: + from amplifier_module_hook_context_intelligence import _patch_inherited_hook_config + + coordinator = SimpleNamespace(session=SimpleNamespace(config={"hooks": "not-a-list"})) + assert _patch_inherited_hook_config(coordinator, {"a": {}}) is False + + def test_returns_false_when_no_matching_hook_entry(self) -> None: + from amplifier_module_hook_context_intelligence import _patch_inherited_hook_config + + coordinator = SimpleNamespace( + session=SimpleNamespace(config={"hooks": [{"module": "other-hook", "config": {}}]}) + ) + assert _patch_inherited_hook_config(coordinator, {"a": {}}) is False + + def test_falls_back_to_coordinator_config_when_no_session(self) -> None: + from amplifier_module_hook_context_intelligence import _patch_inherited_hook_config + + hooks_list = [{"module": "hook-context-intelligence", "config": {}}] + coordinator = SimpleNamespace(config={"hooks": hooks_list}) + + new_raw = {"d1": {"url": "http://d1"}} + assert _patch_inherited_hook_config(coordinator, new_raw) is True + assert hooks_list[0]["config"]["destinations"] == new_raw diff --git a/modules/hook-server-data-ops-lockdown/amplifier_module_hook_server_data_ops_lockdown/__init__.py b/modules/hook-server-data-ops-lockdown/amplifier_module_hook_server_data_ops_lockdown/__init__.py new file mode 100644 index 00000000..6b5198ea --- /dev/null +++ b/modules/hook-server-data-ops-lockdown/amplifier_module_hook_server_data_ops_lockdown/__init__.py @@ -0,0 +1,110 @@ +"""Agent-scoped lockdown hook for server-data-ops (the delete agent). + +Registered on `tool:pre`; denies write_file/edit_file/apply_patch/graph_query +outright, and denies any `delegate` call whose target is not exactly +`context-intelligence:graph-analyst`. Everything else is left untouched +(`continue`). + +Declared on the consuming agent's own `hooks:` (not a behavior-level +`exclude_tools`) so the restriction is owned by server-data-ops alone and +never collides with sibling agents that legitimately need these tools. + +Hook inheritance to a delegated child is additive by default, same as tool +inheritance -- left unexcluded, this hook would also mount on +graph-analyst's spawned session and deny its own graph_query calls. +agents/server-data-ops.md's tool-delegate `exclude_hooks` entry is what +prevents that; this file's deny logic alone does not. + +The delegate-target check exists because the `agents:` frontmatter +allowlist is only enforced when a PARENT spawns this agent -- it does +nothing when server-data-ops runs as the root agent, which let it +delegate to arbitrary agents (verified: `delegate(agent="foundation:file-ops")` +wrote a file to disk unchecked). This hook fires on server-data-ops's own +`tool:pre` calls regardless of root-vs-spawned, so the restriction has to +live here to hold in both cases. + +The check inspects only `tool_name`/`tool_input` (the documented `tool:pre` +payload -- see core:docs/contracts/HOOK_CONTRACT.md) rather than session/agent +identity, because no such field exists in the contract; scoping is therefore +enforced at the delegation boundary (exclude_hooks), not inside this handler. +""" + +from __future__ import annotations + +import logging +from typing import Any + +log = logging.getLogger(__name__) + +__amplifier_module_type__ = "hook" +__all__ = ["mount"] + +# The four tools server-data-ops must never call, regardless of how it +# came to have them available: two direct file-write tools, apply_patch +# (the third way to write files), and graph_query (direct graph access -- +# this agent delegates all searching to graph-analyst instead). +DENIED_TOOLS: frozenset[str] = frozenset({"write_file", "edit_file", "apply_patch", "graph_query"}) + +DENY_REASON = ( + "server-data-ops is a delete agent: it never edits files and never " + "queries the graph directly (it delegates search to graph-analyst)." +) + +# The only agent server-data-ops may delegate to. Must be the namespaced +# form (matches behaviors/context-intelligence-analysis.yaml's registration +# and the delegate tool's own input["agent"] value) -- a bare "graph-analyst" +# would never match a real delegate call. +ALLOWED_DELEGATE_AGENT = "context-intelligence:graph-analyst" + +DELEGATE_DENY_REASON = ( + "server-data-ops may only delegate to graph-analyst (for search); it " + "cannot delegate to other agents to perform actions it is itself " + "restricted from." +) + + +async def _deny_lockdown_tools(event: str, data: dict[str, Any]) -> Any: + """`tool:pre` handler: deny DENIED_TOOLS and off-target delegation. + + Only ever registered for the `tool:pre` event (see mount() below), so + `event` is not branched on here. + + A `delegate` call is denied unless its target agent is exactly + ALLOWED_DELEGATE_AGENT. A missing or empty `agent` field is denied too + (fail closed): it cannot be confirmed to be the allowed target, and + "cannot confirm" must resolve to deny for a security-sensitive gate. + """ + from amplifier_core.models import HookResult # local import: peer dependency + + tool_name = data.get("tool_name") + + if tool_name in DENIED_TOOLS: + return HookResult(action="deny", reason=DENY_REASON) + + if tool_name == "delegate": + tool_input = data.get("tool_input") or {} + target_agent = tool_input.get("agent") + if target_agent != ALLOWED_DELEGATE_AGENT: + return HookResult(action="deny", reason=DELEGATE_DENY_REASON) + + return HookResult(action="continue") + + +async def mount(coordinator: Any, config: dict[str, Any] | None = None) -> Any: + """Register the tool:pre lockdown handler. + + Returns a cleanup callable that unregisters the handler, matching the + hook contract's cleanup convention (core:docs/contracts/HOOK_CONTRACT.md + "Entry Point Pattern"). + """ + unregister = coordinator.hooks.register( + "tool:pre", + _deny_lockdown_tools, + priority=10, + name="server-data-ops-lockdown", + ) + + def cleanup() -> None: + unregister() + + return cleanup diff --git a/modules/hook-server-data-ops-lockdown/pyproject.toml b/modules/hook-server-data-ops-lockdown/pyproject.toml new file mode 100644 index 00000000..3e72a5ea --- /dev/null +++ b/modules/hook-server-data-ops-lockdown/pyproject.toml @@ -0,0 +1,54 @@ +[project] +name = "amplifier-module-hook-server-data-ops-lockdown" +version = "0.1.0" +description = "Agent-scoped tool:pre lockdown hook for server-data-ops -- denies write_file/edit_file/apply_patch/graph_query regardless of how the agent inherits them" +requires-python = ">=3.11" +license = "MIT" + +# No runtime dependencies: amplifier-core (HookResult) is a peer dependency +# provided by the host process (see amplifier-foundation's BUNDLE_GUIDE.md, +# "Declaring amplifier-core as Runtime Dependency" anti-pattern). +dependencies = [] + +[project.entry-points."amplifier.modules"] +hook-server-data-ops-lockdown = "amplifier_module_hook_server_data_ops_lockdown:mount" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv] +package = true + +[tool.hatch.build.targets.wheel] +packages = ["amplifier_module_hook_server_data_ops_lockdown"] + +[dependency-groups] +dev = [ + "amplifier-core>=1.6.0", + "pytest>=9.0.3", + "pytest-asyncio>=0.24", + "pyright>=1.1.411", + "ruff>=0.14", + # Parses agents/server-data-ops.md's frontmatter in + # tests/test_module.py's TestSessionScopeComposition -- verifies the + # companion settings.exclude_hooks fix (see this module's own + # docstring) is actually declared, not just documented. Same version + # floor as this repo's own root pyproject.toml dev group. + "PyYAML>=6.0", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.pyright] +pythonVersion = "3.11" +typeCheckingMode = "basic" +venvPath = "." +venv = ".venv" +extraPaths = ["../.."] + +[tool.ruff] +target-version = "py311" +line-length = 100 diff --git a/modules/hook-server-data-ops-lockdown/tests/test_module.py b/modules/hook-server-data-ops-lockdown/tests/test_module.py new file mode 100644 index 00000000..b2a9e3a4 --- /dev/null +++ b/modules/hook-server-data-ops-lockdown/tests/test_module.py @@ -0,0 +1,252 @@ +"""Unit tests for hook-server-data-ops-lockdown. + +Verifies the module's actual runtime behaviour: + - Module contract: __amplifier_module_type__ == "hook", mount() is a + coroutine, mount() registers a `tool:pre` handler and returns a cleanup + callable. + - The handler denies exactly the four lockdown tools (write_file, + edit_file, apply_patch, graph_query) with a HookResult(action="deny", + reason=...). + - The handler denies a `delegate` call whose target agent is not exactly + ALLOWED_DELEGATE_AGENT ("context-intelligence:graph-analyst") -- this is + the delegation-bypass fix: a behavioral DTU test proved server-data-ops + running as the ROOT agent (no parent to enforce its `agents:` + frontmatter allowlist) could delegate to foundation:file-ops and have it + write a file to disk unchecked. + - The handler allows every other tool call (session_summary, + delete_session, whoami, read_file, load_skill, todo), returning + HookResult(action="continue"). + +Deliberately NOT tested here: that the agent .md frontmatter contains a given +string (declares the hook, excludes it from delegated sessions, or pins the +delegation allowlist). Those are config assertions that only prove "the YAML +says what we typed" -- they give false confidence and fail only if someone +edits the same line the test reads. The real guarantees they gestured at -- +the hook does not leak into graph-analyst's session, and server-data-ops +cannot hand a file-write to another agent -- are behavioural properties, +proven in the DTU security-validation profile, not by grepping a file. +""" + +from __future__ import annotations + +import inspect +from typing import Any +from unittest.mock import MagicMock + +import pytest +from amplifier_core.models import HookResult + +from amplifier_module_hook_server_data_ops_lockdown import ( + ALLOWED_DELEGATE_AGENT, + DELEGATE_DENY_REASON, + DENIED_TOOLS, + DENY_REASON, + _deny_lockdown_tools, + mount, +) + + +def _make_coordinator() -> MagicMock: + coordinator = MagicMock() + coordinator.hooks = MagicMock() + coordinator.hooks.register = MagicMock(return_value=MagicMock(name="unregister")) + return coordinator + + +class TestModuleContract: + def test_module_type_is_hook(self) -> None: + from amplifier_module_hook_server_data_ops_lockdown import ( + __amplifier_module_type__, + ) + + assert __amplifier_module_type__ == "hook" + + def test_mount_is_coroutine(self) -> None: + assert inspect.iscoroutinefunction(mount) + + def test_denied_tools_are_exactly_the_four_named(self) -> None: + assert DENIED_TOOLS == frozenset({"write_file", "edit_file", "apply_patch", "graph_query"}) + + @pytest.mark.asyncio + async def test_mount_registers_tool_pre_handler(self) -> None: + coordinator = _make_coordinator() + + await mount(coordinator, {}) + + coordinator.hooks.register.assert_called_once() + args, kwargs = coordinator.hooks.register.call_args + assert args[0] == "tool:pre" + assert args[1] is _deny_lockdown_tools + assert kwargs.get("priority") == 10 + + @pytest.mark.asyncio + async def test_mount_returns_cleanup_that_unregisters(self) -> None: + coordinator = _make_coordinator() + unregister_fn = MagicMock(name="unregister") + coordinator.hooks.register.return_value = unregister_fn + + cleanup = await mount(coordinator, {}) + assert callable(cleanup) + + cleanup() + unregister_fn.assert_called_once() + + +class TestDenyLockdownTools: + """Direct handler tests -- mirrors HOOK_CONTRACT.md's own test pattern: + `await handler("tool:pre", {"tool_name": ..., "tool_input": ...})`. + """ + + @pytest.mark.asyncio + @pytest.mark.parametrize("tool_name", ["write_file", "edit_file", "apply_patch", "graph_query"]) + async def test_denies_each_lockdown_tool(self, tool_name: str) -> None: + result = await _deny_lockdown_tools("tool:pre", {"tool_name": tool_name, "tool_input": {}}) + + assert isinstance(result, HookResult) + assert result.action == "deny" + assert result.reason == DENY_REASON + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "tool_name", + [ + "session_summary", + "delete_session", + "whoami", + "read_file", + "load_skill", + "todo", + ], + ) + async def test_allows_every_other_tool(self, tool_name: str) -> None: + """`delegate` is deliberately excluded from this list -- it has its + own target-checked behaviour, covered by TestDelegateTargetLockdown + below, and an empty tool_input (as used here) would now be denied + (fail closed on a missing `agent` field).""" + result = await _deny_lockdown_tools("tool:pre", {"tool_name": tool_name, "tool_input": {}}) + + assert isinstance(result, HookResult) + assert result.action == "continue" + assert result.reason is None + + @pytest.mark.asyncio + async def test_missing_tool_name_is_allowed(self) -> None: + """Defensive: a data dict with no tool_name key must never be denied + (missing information is not evidence of a lockdown-tool call).""" + result = await _deny_lockdown_tools("tool:pre", {}) + + assert result.action == "continue" + + @pytest.mark.asyncio + async def test_deny_reason_is_plain_and_explains_delegation(self) -> None: + result: Any = await _deny_lockdown_tools( + "tool:pre", {"tool_name": "graph_query", "tool_input": {}} + ) + + assert "delete agent" in result.reason + assert "graph-analyst" in result.reason + + +class TestDelegateTargetLockdown: + """Direct handler tests for the delegation-bypass fix. + + A behavioral DTU test proved server-data-ops, running as the ROOT agent + (no parent session to enforce its `agents:` frontmatter allowlist), could + call `delegate(agent="foundation:file-ops")` and have it write a file to + disk unchecked. These tests prove the handler itself now closes that hole, + independent of any frontmatter allowlist. + """ + + @pytest.mark.asyncio + async def test_delegate_to_allowed_agent_is_allowed(self) -> None: + result = await _deny_lockdown_tools( + "tool:pre", + { + "tool_name": "delegate", + "tool_input": {"agent": ALLOWED_DELEGATE_AGENT, "instruction": "search"}, + }, + ) + + assert isinstance(result, HookResult) + assert result.action == "continue" + assert result.reason is None + + @pytest.mark.asyncio + async def test_delegate_to_allowed_agent_is_the_namespaced_graph_analyst(self) -> None: + """Pin the exact allowed string so a future edit that changes it is + caught here, not just discovered behaviorally.""" + assert ALLOWED_DELEGATE_AGENT == "context-intelligence:graph-analyst" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "target_agent", + [ + "foundation:file-ops", + "self", + "graph-analyst", # bare (unnamespaced) form -- must NOT match + "context-intelligence:server-data-ops", + "context-intelligence:session-navigator", + ], + ) + async def test_delegate_to_any_other_agent_is_denied(self, target_agent: str) -> None: + result = await _deny_lockdown_tools( + "tool:pre", + { + "tool_name": "delegate", + "tool_input": {"agent": target_agent, "instruction": "do something"}, + }, + ) + + assert isinstance(result, HookResult) + assert result.action == "deny" + assert result.reason == DELEGATE_DENY_REASON + + @pytest.mark.asyncio + async def test_delegate_with_missing_agent_field_is_denied(self) -> None: + """Fail closed: e.g. a resume-by-session_id call that omits `agent` + entirely must be denied, not allowed through.""" + result = await _deny_lockdown_tools( + "tool:pre", + { + "tool_name": "delegate", + "tool_input": {"session_id": "abc123", "instruction": "continue"}, + }, + ) + + assert isinstance(result, HookResult) + assert result.action == "deny" + assert result.reason == DELEGATE_DENY_REASON + + @pytest.mark.asyncio + async def test_delegate_with_empty_agent_field_is_denied(self) -> None: + """Fail closed: an explicit empty string is not evidence it targets + the allowed agent.""" + result = await _deny_lockdown_tools( + "tool:pre", + {"tool_name": "delegate", "tool_input": {"agent": "", "instruction": "do something"}}, + ) + + assert isinstance(result, HookResult) + assert result.action == "deny" + assert result.reason == DELEGATE_DENY_REASON + + @pytest.mark.asyncio + async def test_delegate_with_missing_tool_input_is_denied(self) -> None: + """Fail closed: no tool_input key at all (malformed/edge-case event + payload) must not be treated as an allowed delegate.""" + result = await _deny_lockdown_tools("tool:pre", {"tool_name": "delegate"}) + + assert isinstance(result, HookResult) + assert result.action == "deny" + assert result.reason == DELEGATE_DENY_REASON + + @pytest.mark.asyncio + async def test_denied_tools_still_deny_even_though_delegate_check_exists(self) -> None: + """Regression guard: adding the delegate-target branch must not + change the outright deny behaviour for the original four tools.""" + for tool_name in DENIED_TOOLS: + result = await _deny_lockdown_tools( + "tool:pre", {"tool_name": tool_name, "tool_input": {}} + ) + assert result.action == "deny" + assert result.reason == DENY_REASON diff --git a/modules/hook-server-data-ops-lockdown/uv.lock b/modules/hook-server-data-ops-lockdown/uv.lock new file mode 100644 index 00000000..410c956a --- /dev/null +++ b/modules/hook-server-data-ops-lockdown/uv.lock @@ -0,0 +1,436 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "amplifier-core" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tomli" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/cd/8b0b520bf0de741ea73e069aaf64aca28c9f4ce91a7b8b9239193a6c4c1b/amplifier_core-1.6.1-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c0f711d8408de78e53e5deddcb38b7240c5c1c497ca51eeaaeff23559b3d3c48", size = 8281633, upload-time = "2026-08-10T02:38:11.98Z" }, + { url = "https://files.pythonhosted.org/packages/14/83/f4fb297d87d35b9d74058da02bb153e12f7891ab62b3aaf7e0857f877798/amplifier_core-1.6.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b08f37e2c0b1611349a0e25d5bf9bfdfae3afcee35488f8e26bba1cdd400503b", size = 7366930, upload-time = "2026-08-10T02:38:14.105Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/5eb9cecf92d8053c5e6d46ad9668c3ed3558d5423845c1dced1f266b2a38/amplifier_core-1.6.1-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ebf7e3993c76ea506e70ac7844b286c3ba2e9127b3bcb350fa4fcd2dcdbd38d", size = 7659512, upload-time = "2026-08-10T02:38:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/121f054e3d079dc33d83f3d8ba9af50fd9f7694c3e2ba3d7d23d7c157d48/amplifier_core-1.6.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c957cd0671d2a003f2c8f7d6a41bd6e808f97d183c57b97e7700bf4c912621d", size = 8678425, upload-time = "2026-08-10T02:38:18.243Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/bfc217f4a9ed2d033995fc59847f1fee2e1b17130632fcb0e0981a1a311b/amplifier_core-1.6.1-cp311-abi3-win_amd64.whl", hash = "sha256:50c80bcfa1f6efe769b19e7af18c925024c7553d4db08880727241709dd44eae", size = 8976601, upload-time = "2026-08-10T02:38:20.505Z" }, + { url = "https://files.pythonhosted.org/packages/a5/14/5f330452c92c6c5d35c51ad5311301949ce5db4d1a1a901456f3ee43eaac/amplifier_core-1.6.1-cp311-abi3-win_arm64.whl", hash = "sha256:cd8b617f132cf5d1ca3e5187d5f831d1f2a508bb40d07b2ab1085961bcb9e1a9", size = 7744837, upload-time = "2026-08-10T02:38:22.562Z" }, +] + +[[package]] +name = "amplifier-module-hook-server-data-ops-lockdown" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "amplifier-core" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pyyaml" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "amplifier-core", specifier = ">=1.6.0" }, + { name = "pyright", specifier = ">=1.1.411" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "ruff", specifier = ">=0.14" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.411" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, + { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, + { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, + { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, + { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, + { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, + { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, + { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] diff --git a/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/__init__.py b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/__init__.py index 8a5e7b69..02ac3079 100644 --- a/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/__init__.py +++ b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/__init__.py @@ -1,9 +1,17 @@ -"""Context Intelligence read tools — graph_query and blob_read. +"""Context Intelligence read tools — graph_query, blob_read, and whoami. -Both tools share one ToolConfigResolver, so sources has a single +All three tools share one ToolConfigResolver, so sources has a single config namespace: overrides.tool-context-intelligence-query.config.sources. -Two tools, one mount(): idiomatic multi-tool module (same as tool-filesystem +WhoamiTool itself lives in the shared context_intelligence library +(context_intelligence/whoami_tool.py), not in this module's own package -- +it is ALSO mounted by tool-server-data-ops (the delete agent's module), +which needs identity for ownership checks but must not have graph_query. +Importing the same class from the shared location keeps the two mounts +in lock-step with zero duplication. No agent mounts both this module AND +tool-server-data-ops, so there is no "whoami" name collision. + +Three tools, one mount(): idiomatic multi-tool module (same as tool-filesystem which mounts read_file / write_file / edit_file from one mount() call). """ @@ -16,10 +24,10 @@ async def mount(coordinator: Any, config: Any) -> None: - """Mount both CI read tools, sharing one ToolConfigResolver. + """Mount all three CI read tools, sharing one ToolConfigResolver. The resolver is built ONCE from the module's config and injected into - both tools. Tool constructors no longer accept config — the resolver IS + all three tools. Tool constructors no longer accept config — the resolver IS the config surface. The hook resolver is NOT fetched here; each tool fetches it lazily at @@ -29,6 +37,7 @@ async def mount(coordinator: Any, config: Any) -> None: untouched. """ from context_intelligence.tool_resolver import ToolConfigResolver + from context_intelligence.whoami_tool import WhoamiTool from .blob_read_tool import BlobReadTool from .graph_query_tool import GraphQueryTool @@ -39,6 +48,7 @@ async def mount(coordinator: Any, config: Any) -> None: resolver.validate_sources() gq = GraphQueryTool(coordinator, resolver) br = BlobReadTool(coordinator, resolver) + whoami = WhoamiTool(coordinator, resolver) await coordinator.mount("tools", gq, name=gq.name) # "graph_query" await coordinator.mount("tools", br, name=br.name) # "blob_read" - return None # kernel ignores non-callable returns; resolver is pure → no cleanup + await coordinator.mount("tools", whoami, name=whoami.name) # "whoami" diff --git a/modules/tool-context-intelligence-query/tests/test_module.py b/modules/tool-context-intelligence-query/tests/test_module.py index 78ac8819..ba7ecb50 100644 --- a/modules/tool-context-intelligence-query/tests/test_module.py +++ b/modules/tool-context-intelligence-query/tests/test_module.py @@ -1,9 +1,9 @@ """Module-level contract tests for tool-context-intelligence-query. -Tests for the merged two-tool module: mount registers both tools from one call, -the ToolConfigResolver is shared (one instance, identical resolution), the lazy -hook lookup stays lazy (not cached at mount time), and malformed/empty destination -inputs fail loud or fall through correctly. +Tests for the merged three-tool module: mount registers all three tools from +one call, the ToolConfigResolver is shared (one instance, identical resolution), +the lazy hook lookup stays lazy (not cached at mount time), and malformed/empty +destination inputs fail loud or fall through correctly. """ from __future__ import annotations @@ -93,22 +93,22 @@ def test_mount_signature_has_coordinator_and_config(self) -> None: # --------------------------------------------------------------------------- -# TestMountRegistersExactlyTwoTools +# TestMountRegistersExactlyThreeTools # --------------------------------------------------------------------------- -class TestMountRegistersExactlyTwoTools: - """mount() must register exactly two tools with distinct names.""" +class TestMountRegistersExactlyThreeTools: + """mount() must register exactly three tools with distinct names.""" - async def test_mount_registers_exactly_two_tools(self) -> None: + async def test_mount_registers_exactly_three_tools(self) -> None: from amplifier_module_tool_context_intelligence_query import mount coordinator = _make_coordinator() await mount(coordinator, config={}) - assert coordinator.mount.call_count == 2 + assert coordinator.mount.call_count == 3 - async def test_both_tool_calls_use_tools_category(self) -> None: + async def test_all_tool_calls_use_tools_category(self) -> None: from amplifier_module_tool_context_intelligence_query import mount coordinator = _make_coordinator() @@ -117,14 +117,14 @@ async def test_both_tool_calls_use_tools_category(self) -> None: for call in coordinator.mount.call_args_list: assert call.args[0] == "tools" - async def test_tool_names_are_graph_query_and_blob_read(self) -> None: + async def test_tool_names_are_graph_query_blob_read_and_whoami(self) -> None: from amplifier_module_tool_context_intelligence_query import mount coordinator = _make_coordinator() await mount(coordinator, config={}) registered_names = {call.kwargs["name"] for call in coordinator.mount.call_args_list} - assert registered_names == {"graph_query", "blob_read"} + assert registered_names == {"graph_query", "blob_read", "whoami"} async def test_mounted_tools_are_protocol_compliant(self) -> None: from amplifier_module_tool_context_intelligence_query import mount @@ -171,14 +171,14 @@ class TestSeam1SkillSyncLifecycleCutover: pre-change code. """ - async def test_mount_registers_both_tools_by_name(self) -> None: + async def test_mount_registers_all_tools_by_name(self) -> None: from amplifier_module_tool_context_intelligence_query import mount coordinator = _make_coordinator() await mount(coordinator, config={}) registered_names = {call.kwargs["name"] for call in coordinator.mount.call_args_list} - assert registered_names == {"graph_query", "blob_read"} + assert registered_names == {"graph_query", "blob_read", "whoami"} async def test_module_has_no_on_session_ready(self) -> None: import amplifier_module_tool_context_intelligence_query as module @@ -263,8 +263,8 @@ async def test_stale_skill_sync_enabled_key_is_inert_at_mount(self) -> None: class TestSharedResolverInvariant: """The ToolConfigResolver is shared: one instance, identical resolution.""" - async def test_both_tools_have_same_resolver_instance(self) -> None: - """gq._tool_resolver is br._tool_resolver: same object from mount().""" + async def test_all_three_tools_have_same_resolver_instance(self) -> None: + """gq/br/whoami._tool_resolver are all the SAME object from mount().""" from amplifier_module_tool_context_intelligence_query import mount coordinator = _make_coordinator() @@ -273,10 +273,12 @@ async def test_both_tools_have_same_resolver_instance(self) -> None: tools = {call.kwargs["name"]: call.args[1] for call in coordinator.mount.call_args_list} gq = tools["graph_query"] br = tools["blob_read"] + whoami = tools["whoami"] assert gq._tool_resolver is br._tool_resolver + assert gq._tool_resolver is whoami._tool_resolver async def test_shared_resolver_consistency_same_url_and_api_key(self) -> None: - """Both tools resolve to the SAME (url, api_key) from sources. + """All three tools resolve to the SAME (url, api_key) from sources. This is the load-bearing correctness invariant: with a shared resolver, divergent read-endpoint config is structurally impossible. @@ -295,13 +297,15 @@ async def test_shared_resolver_consistency_same_url_and_api_key(self) -> None: tools = {call.kwargs["name"]: call.args[1] for call in coordinator.mount.call_args_list} gq = tools["graph_query"] br = tools["blob_read"] + whoami = tools["whoami"] # Resolve using the shared resolver (no hook resolver needed for tier-1 hit) gq_conn = resolve_query_connection(None, gq._tool_resolver) br_conn = resolve_query_connection(None, br._tool_resolver) + whoami_conn = resolve_query_connection(None, whoami._tool_resolver) - assert gq_conn.url == br_conn.url == "http://read.example.com" - assert gq_conn.api_key == br_conn.api_key == "shared-key" + assert gq_conn.url == br_conn.url == whoami_conn.url == "http://read.example.com" + assert gq_conn.api_key == br_conn.api_key == whoami_conn.api_key == "shared-key" async def test_concurrent_resolution_is_consistent(self) -> None: """Execute both tools 'concurrently'; both resolve to the same endpoint. @@ -649,7 +653,7 @@ async def test_mount_does_not_raise_with_one_bad_source(self) -> None: result = await mount(coordinator, config=config) assert result is None - async def test_mount_registers_both_tools_with_one_bad_source(self) -> None: + async def test_mount_registers_all_tools_with_one_bad_source(self) -> None: from amplifier_module_tool_context_intelligence_query import mount config = { @@ -661,9 +665,9 @@ async def test_mount_registers_both_tools_with_one_bad_source(self) -> None: coordinator = _make_coordinator() await mount(coordinator, config=config) - assert coordinator.mount.call_count == 2 + assert coordinator.mount.call_count == 3 registered_names = {call.kwargs["name"] for call in coordinator.mount.call_args_list} - assert registered_names == {"graph_query", "blob_read"} + assert registered_names == {"graph_query", "blob_read", "whoami"} async def test_mount_logs_warning_with_one_bad_source(self, caplog: Any) -> None: import logging diff --git a/modules/tool-context-intelligence-set-filters/amplifier_module_tool_context_intelligence_set_filters/__init__.py b/modules/tool-context-intelligence-set-filters/amplifier_module_tool_context_intelligence_set_filters/__init__.py new file mode 100644 index 00000000..66645830 --- /dev/null +++ b/modules/tool-context-intelligence-set-filters/amplifier_module_tool_context_intelligence_set_filters/__init__.py @@ -0,0 +1,114 @@ +"""Same-session ingestion-filter tool. + +Exposes the root session's ``context_intelligence.set_ingestion_filters`` capability +(registered by the ingestion hook) as an agent-callable tool, so the ROOT agent +can make a destination `exclude` edit made during a session take effect +immediately -- no restart -- against the already-running session's ingestion. + +Only meaningful in a session that actually mounts the ingestion hook (i.e. the +ROOT session via behaviors/context-intelligence-logging.yaml). A delegated agent +that does not mount the hook has no such capability on its own coordinator; the +tool then fails loud rather than silently doing nothing -- which is exactly the +negative-control boundary (a delegate cannot reach the root's live hook state). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from amplifier_core.models import ToolResult + +__amplifier_module_type__ = "tool" +__all__ = ["mount"] + +_CAP = "context_intelligence.set_ingestion_filters" +_VERIFY_CAP = "context_intelligence.verify_ingestion_consistency" +_DEFAULT_SETTINGS = str(Path("~/.amplifier/settings.yaml").expanduser()) + + +class SetIngestionFiltersTool: + """Apply the ingestion hook's per-destination include/exclude live.""" + + def __init__(self, coordinator: Any) -> None: + self._coordinator = coordinator + + @property + def name(self) -> str: + return "set_ingestion_filters" + + @property + def description(self) -> str: + return ( + "Make a Context Intelligence ingestion destination `exclude` change take " + "effect IMMEDIATELY in the CURRENTLY-RUNNING session, without a restart. " + "Call this AFTER the exclude edit has been written to settings.yaml. Re-reads " + "the destinations block from settings.yaml, re-evaluates routing for this " + "session's working directory, and swaps the live per-destination dispatchers " + "(drain-safe). Returns the new active destinations and their include/exclude, " + "and fails loud if the live filter does not match what is on disk." + ) + + @property + def input_schema(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "settings_path": { + "type": "string", + "description": ( + "Path to the settings.yaml carrying the destinations block. " + f"Defaults to {_DEFAULT_SETTINGS}." + ), + }, + "verify_only": { + "type": "boolean", + "description": ( + "If true, do NOT apply -- only fail-loud compare the session's " + "LIVE exclude filter against settings.yaml on disk (both directions)." + ), + }, + }, + } + + async def execute(self, input: dict[str, Any]) -> ToolResult: # noqa: A002 + settings_path = input.get("settings_path") or _DEFAULT_SETTINGS + verify_only = bool(input.get("verify_only")) + + if verify_only: + verify = self._coordinator.get_capability(_VERIFY_CAP) + if verify is None: + return ToolResult( + success=False, + output={ + "error": f"capability {_VERIFY_CAP} unavailable in this session " + "(no ingestion hook mounted here) -- cannot verify." + }, + ) + try: + result = verify(settings_path) + except Exception as exc: # noqa: BLE001 - surface the fail-loud reason to the agent + return ToolResult(success=False, output={"error": str(exc)}) + return ToolResult(success=True, output=result) + + set_filters = self._coordinator.get_capability(_CAP) + if set_filters is None: + return ToolResult( + success=False, + output={ + "error": f"capability {_CAP} unavailable in this session (no " + "ingestion hook mounted here) -- this session's fan-out was NOT " + "changed. The set_ingestion_filters tool only affects the session " + "that mounts the ingestion hook (the root session)." + }, + ) + try: + report = await set_filters(settings_path=settings_path, verify_disk=True) + except Exception as exc: # noqa: BLE001 - surface the fail-loud reason to the agent + return ToolResult(success=False, output={"error": str(exc)}) + return ToolResult(success=True, output=report) + + +async def mount(coordinator: Any, config: Any) -> None: + tool = SetIngestionFiltersTool(coordinator) + await coordinator.mount("tools", tool, name=tool.name) diff --git a/modules/tool-context-intelligence-set-filters/pyproject.toml b/modules/tool-context-intelligence-set-filters/pyproject.toml new file mode 100644 index 00000000..4e0e30d2 --- /dev/null +++ b/modules/tool-context-intelligence-set-filters/pyproject.toml @@ -0,0 +1,39 @@ +[project] +name = "amplifier-module-tool-context-intelligence-set-filters" +version = "0.1.0" +description = "Same-session ingestion filter tool - make a destination include/exclude change take effect live, without a restart" +requires-python = ">=3.11" +license = "MIT" + +dependencies = [ + "amplifier-bundle-context-intelligence @ git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main", +] + +[project.entry-points."amplifier.modules"] +tool-context-intelligence-set-filters = "amplifier_module_tool_context_intelligence_set_filters:mount" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv] +package = true + +[tool.hatch.build.targets.wheel] +packages = ["amplifier_module_tool_context_intelligence_set_filters"] + +[tool.hatch.metadata] +allow-direct-references = true + +[dependency-groups] +dev = [ + "amplifier-core>=1.6.0", + "pytest>=9.0.3", + "pytest-asyncio>=0.24", + "pyright>=1.1.411", + "ruff>=0.14", +] + +[tool.ruff] +target-version = "py311" +line-length = 100 diff --git a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py new file mode 100644 index 00000000..70f4b8e0 --- /dev/null +++ b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py @@ -0,0 +1,48 @@ +"""Context Intelligence server data-ops tools -- session_summary, +delete_session, and whoami. + +All three tools share one ToolConfigResolver (single config namespace: +overrides.tool-server-data-ops.config.sources). + +WhoamiTool itself lives in the shared context_intelligence library, not in +this module's own package, because it is also mounted by +tool-context-intelligence-query -- importing the same class keeps both +mounts in lock-step with zero duplication. +""" + +from __future__ import annotations + +from typing import Any + +__amplifier_module_type__ = "tool" +__all__ = ["mount"] + + +async def mount(coordinator: Any, config: Any) -> None: + """Mount all three server-data-ops tools, sharing one ToolConfigResolver. + + The resolver is built ONCE from the module's config and injected into + all three tools. Tool constructors do not accept config -- the resolver + IS the config surface. + + The hook resolver is NOT fetched here; each tool fetches it lazily at + first execute() because tools mount before hooks (kernel phase order is + orchestrator -> context -> providers -> tools -> hooks -- CONTRACTS.md + section Module Lifecycle Methods). + """ + from context_intelligence.tool_resolver import ToolConfigResolver + from context_intelligence.whoami_tool import WhoamiTool + + from .delete_session_tool import DeleteSessionTool + from .session_summary_tool import SessionSummaryTool + + resolver = ToolConfigResolver(config or {}, coordinator) + # WARN-only diagnostic pass -- never raises; hard validation is per-source + # at query time (see tool_resolver.py: validate_source()). + resolver.validate_sources() + summary = SessionSummaryTool(coordinator, resolver) + delete = DeleteSessionTool(coordinator, resolver) + whoami = WhoamiTool(coordinator, resolver) + await coordinator.mount("tools", summary, name=summary.name) + await coordinator.mount("tools", delete, name=delete.name) + await coordinator.mount("tools", whoami, name=whoami.name) diff --git a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/delete_session_tool.py b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/delete_session_tool.py new file mode 100644 index 00000000..ba8ba3d4 --- /dev/null +++ b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/delete_session_tool.py @@ -0,0 +1,219 @@ +"""DeleteSessionTool -- agent-facing tool that permanently deletes a session. + +Implements the Amplifier Tool protocol. Endpoint selection goes through +``resolve_query_connection`` (see context_intelligence/tool_resolver.py for +the authoritative selection rule); every result carries the resolved +``source``. + +This is a REAL, PERMANENT change with no preview flag here -- the caller is +responsible for running session_summary and getting explicit confirmation +before calling this. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from amplifier_core.models import ToolResult +from context_intelligence.client import AsyncCIClient, CIClientError +from context_intelligence.tool_resolver import ( + ToolConfigResolver, + _connectable_pool, + _origin_dict, + resolve_query_connection, +) + + +class DeleteSessionTool: + """Permanently delete one session's whole graph from the context-intelligence server. + + Implements the Amplifier Tool protocol (name, description, input_schema, + execute). Configuration and provenance are resolved via + resolve_query_connection() at execute() time, over the connectable pool + (tool sources union the hook's upload destinations). + """ + + def __init__(self, coordinator: Any, resolver: ToolConfigResolver | None = None) -> None: + self._coordinator = coordinator + self._tool_resolver = resolver or ToolConfigResolver({}, coordinator) + self._hook_resolver: Any | None = None + + @property + def name(self) -> str: + return "delete_session" + + @property + def description(self) -> str: + return ( + "Permanently delete one session's whole graph (nodes, relationships, " + "and blobs) from the context-intelligence server. This is a REAL, " + "PERMANENT change -- there is no undo and no preview flag here. " + "ALWAYS call session_summary first to show the user what would be " + "removed, and get their explicit confirmation before calling this. " + "If the session is still receiving data, the server refuses with a " + "clear error rather than deleting a live session. Every result names " + "the `source` (name/url/origin) the delete was sent to -- ALWAYS " + "state it in your answer." + ) + + @property + def input_schema(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": ( + "The id of the session to permanently delete. Required " + "unless list_sources=true." + ), + }, + "source": { + "type": "string", + "description": ( + "Optional name of a specific connectable endpoint (server) to " + "delete from -- either a configured source OR a hook upload " + "destination (the full connectable set; call with " + "list_sources=true to see the names). Omitting `source` uses " + "the default endpoint: the single configured source, or -- if " + "no sources are configured -- the first destination in config " + "order. The only case where omitting it errors is when 2+ " + "SOURCES are configured (then you must pass source=, and " + "the error lists the valid names)." + ), + }, + "list_sources": { + "type": "boolean", + "description": ( + "When true, do NOT delete anything. Return the connectable " + "set -- every server this tool can reach, with name, url, and " + "origin (source/destination). Use this to discover valid " + "`source` values before selecting one." + ), + }, + }, + "required": [], + } + + async def execute(self, input: dict[str, Any]) -> ToolResult: + from context_intelligence.tool_resolver import SourceSelectionError + + # Late-mount upgrade: retry hook capability lookup on every call while + # _hook_resolver is None (hook may mount after the tool). + if self._hook_resolver is None: + self._hook_resolver = self._coordinator.get_capability( + "context_intelligence.hook_config_resolver" + ) + + if input.get("list_sources"): + pool = _connectable_pool(self._tool_resolver, self._hook_resolver) + return ToolResult( + success=True, + output={ + "connectable_set": [ + {"name": e.name, "url": e.url, "origin": e.kind} for e in pool.values() + ] + }, + ) + + source_name = input.get("source") + try: + conn = resolve_query_connection( + self._hook_resolver, self._tool_resolver, source_name=source_name + ) + except SourceSelectionError as exc: + return ToolResult( + success=False, + error={ + "message": str(exc), + "type": exc.error_type, # "unknown_source" | "ambiguous_source_selection" + "valid_sources": exc.valid_names, + }, + ) + except ValueError as exc: + # The selected source itself is misconfigured -- names only it. + return ToolResult( + success=False, + error={"message": str(exc), "type": "source_misconfigured"}, + ) + + if not conn.url: + return ToolResult( + success=False, + error={ + "message": "context-intelligence server URL not configured", + "type": "configuration_error", + }, + ) + + if "session_id" not in input: + # Endpoint already resolved above -> attach provenance: `source` is + # present on failures that occur AFTER an endpoint is chosen. + return ToolResult( + success=False, + error={ + "message": "session_id is required unless list_sources=true", + "type": "validation_error", + "source": _origin_dict(conn.origin), + }, + ) + session_id: str = input["session_id"] + + async_client = AsyncCIClient( + server_url=conn.url, + api_key=conn.api_key or "", + auth_strategy=conn.auth_strategy, + timeout=self._tool_resolver.request_timeout, + ) + # A 409 with a Retry-After hint means the graph is still draining -- a + # transient, retryable refusal. Honor the server's hint with a bounded + # backoff so a normal "just finished" session drains and deletes without + # the user having to poll by hand; a genuinely still-live session simply + # exhausts the bound and returns a precise "still draining" message + # rather than blocking forever. An ambiguous-id 409 has no Retry-After, + # so it is never retried. + max_retries = 3 + attempt = 0 + while True: + try: + result = await async_client.delete_session(session_id) + break + except CIClientError as exc: + if exc.status_code == 409 and exc.retry_after is not None and attempt < max_retries: + attempt += 1 + await asyncio.sleep(exc.retry_after) + continue + # success=False + output unset is safe: ToolResult.model_post_init + # back-fills output from error["message"] when output is None. Do NOT + # also set output= here or that back-fill is suppressed. + origin_name = conn.origin.name if conn.origin and conn.origin.name else conn.url + message = f"delete failed against {origin_name}: {exc}" + if exc.status_code == 404: + message = f"unknown session {session_id!r} on {origin_name}" + elif exc.status_code == 409 and exc.retry_after is not None: + message = ( + f"session {session_id!r} on {origin_name} is still receiving " + f"data (still draining after {attempt} automatic retr" + f"{'y' if attempt == 1 else 'ies'}); wait ~{exc.retry_after}s " + "and try again" + ) + elif exc.status_code == 409: + message = ( + f"session {session_id!r} on {origin_name} could not be deleted: " + "the id is ambiguous across workspaces" + ) + return ToolResult( + success=False, + error={ + "message": message, + "type": exc.error_type, # connection_error|timeout|http_status|decode_error + "source": _origin_dict(conn.origin), + **({"status_code": exc.status_code} if exc.status_code is not None else {}), + **({"retry_after": exc.retry_after} if exc.retry_after is not None else {}), + }, + ) + return ToolResult( + success=True, + output={"source": _origin_dict(conn.origin), "result": result}, + ) diff --git a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/session_summary_tool.py b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/session_summary_tool.py new file mode 100644 index 00000000..be2080bd --- /dev/null +++ b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/session_summary_tool.py @@ -0,0 +1,191 @@ +"""SessionSummaryTool -- agent-facing tool for previewing a session before delete. + +Implements the Amplifier Tool protocol. Endpoint selection goes through +``resolve_query_connection`` (see context_intelligence/tool_resolver.py for +the authoritative selection rule -- the same helper the read tools use); +every result carries the resolved ``source``. + +Read-only: fetches session facts so the caller can preview what would be +removed before deleting. +""" + +from __future__ import annotations + +from typing import Any + +from amplifier_core.models import ToolResult +from context_intelligence.client import AsyncCIClient, CIClientError +from context_intelligence.tool_resolver import ( + ToolConfigResolver, + _connectable_pool, + _origin_dict, + resolve_query_connection, +) + + +class SessionSummaryTool: + """Fetch the preview facts for a session from the context-intelligence server. + + Implements the Amplifier Tool protocol (name, description, input_schema, + execute). Configuration and provenance are resolved via + resolve_query_connection() at execute() time, over the connectable pool + (tool sources union the hook's upload destinations). + """ + + def __init__(self, coordinator: Any, resolver: ToolConfigResolver | None = None) -> None: + self._coordinator = coordinator + self._tool_resolver = resolver or ToolConfigResolver({}, coordinator) + self._hook_resolver: Any | None = None + + @property + def name(self) -> str: + return "session_summary" + + @property + def description(self) -> str: + return ( + "Fetch the preview facts for one session from the context-intelligence " + "server: who created it, how many nodes/edges/blobs it has, when it " + "started and last changed, and whether it is safe to delete right now. " + "This makes NO changes -- it is a read, always call it BEFORE " + "delete_session so the user can see what would be removed. Every result " + "names the `source` (name/url/origin) that answered -- ALWAYS state it " + "in your answer." + ) + + @property + def input_schema(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": ( + "The id of the session to preview. Required unless list_sources=true." + ), + }, + "source": { + "type": "string", + "description": ( + "Optional name of a specific connectable endpoint (server) to " + "ask -- either a configured source OR a hook upload destination " + "(the full connectable set; call with list_sources=true to see " + "the names). Omitting `source` uses the default endpoint: the " + "single configured source, or -- if no sources are configured -- " + "the first destination in config order. The only case where " + "omitting it errors is when 2+ SOURCES are configured (then you " + "must pass source=, and the error lists the valid names)." + ), + }, + "list_sources": { + "type": "boolean", + "description": ( + "When true, do NOT look up a session. Return the connectable " + "set -- every server this tool can reach, with name, url, and " + "origin (source/destination). Use this to discover valid " + "`source` values, or to tell the user which servers a session " + "could be on, before selecting one." + ), + }, + }, + "required": [], + } + + async def execute(self, input: dict[str, Any]) -> ToolResult: + from context_intelligence.tool_resolver import SourceSelectionError + + # Late-mount upgrade: retry hook capability lookup on every call while + # _hook_resolver is None (hook may mount after the tool). + if self._hook_resolver is None: + self._hook_resolver = self._coordinator.get_capability( + "context_intelligence.hook_config_resolver" + ) + + if input.get("list_sources"): + pool = _connectable_pool(self._tool_resolver, self._hook_resolver) + return ToolResult( + success=True, + output={ + "connectable_set": [ + {"name": e.name, "url": e.url, "origin": e.kind} for e in pool.values() + ] + }, + ) + + source_name = input.get("source") + try: + conn = resolve_query_connection( + self._hook_resolver, self._tool_resolver, source_name=source_name + ) + except SourceSelectionError as exc: + return ToolResult( + success=False, + error={ + "message": str(exc), + "type": exc.error_type, # "unknown_source" | "ambiguous_source_selection" + "valid_sources": exc.valid_names, + }, + ) + except ValueError as exc: + # The selected source itself is misconfigured -- names only it. + return ToolResult( + success=False, + error={"message": str(exc), "type": "source_misconfigured"}, + ) + + if not conn.url: + return ToolResult( + success=False, + error={ + "message": "context-intelligence server URL not configured", + "type": "configuration_error", + }, + ) + + if "session_id" not in input: + # Endpoint already resolved above -> attach provenance: `source` is + # present on failures that occur AFTER an endpoint is chosen. + return ToolResult( + success=False, + error={ + "message": "session_id is required unless list_sources=true", + "type": "validation_error", + "source": _origin_dict(conn.origin), + }, + ) + session_id: str = input["session_id"] + + async_client = AsyncCIClient( + server_url=conn.url, + api_key=conn.api_key or "", + auth_strategy=conn.auth_strategy, + timeout=self._tool_resolver.request_timeout, + ) + try: + summary = await async_client.session_summary(session_id) + except CIClientError as exc: + # success=False + output unset is safe: ToolResult.model_post_init + # back-fills output from error["message"] when output is None. Do NOT + # also set output= here or that back-fill is suppressed. + origin_name = conn.origin.name if conn.origin and conn.origin.name else conn.url + message = f"session lookup failed against {origin_name}: {exc}" + if exc.status_code == 404: + message = f"unknown session {session_id!r} on {origin_name}" + elif exc.status_code == 409: + message = ( + f"session {session_id!r} on {origin_name} is still receiving data, " + "or the id is ambiguous across workspaces" + ) + return ToolResult( + success=False, + error={ + "message": message, + "type": exc.error_type, # connection_error|timeout|http_status|decode_error + "source": _origin_dict(conn.origin), + **({"status_code": exc.status_code} if exc.status_code is not None else {}), + }, + ) + return ToolResult( + success=True, + output={"source": _origin_dict(conn.origin), "summary": summary}, + ) diff --git a/modules/tool-server-data-ops/pyproject.toml b/modules/tool-server-data-ops/pyproject.toml new file mode 100644 index 00000000..c1890b87 --- /dev/null +++ b/modules/tool-server-data-ops/pyproject.toml @@ -0,0 +1,53 @@ +[project] +name = "amplifier-module-tool-server-data-ops" +version = "0.1.0" +description = "CI server data-ops tools -- session_summary (preview) and delete_session (permanent delete) against the context-intelligence server" +requires-python = ">=3.11" +license = "MIT" + +dependencies = [ + "amplifier-bundle-context-intelligence @ git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main", + "httpx>=0.28.1", + "idna>=3.15", +] + +[project.entry-points."amplifier.modules"] +tool-server-data-ops = "amplifier_module_tool_server_data_ops:mount" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv] +package = true + +[tool.hatch.build.targets.wheel] +packages = ["amplifier_module_tool_server_data_ops"] + +[tool.hatch.metadata] +# Required to build a wheel that carries a PEP 508 direct-reference (git+https) dependency. +allow-direct-references = true + +[dependency-groups] +dev = [ + "amplifier-core>=1.6.0", + "pytest>=9.0.3", + "pytest-asyncio>=0.24", + "pyright>=1.1.411", + "ruff>=0.14", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.pyright] +pythonVersion = "3.11" +typeCheckingMode = "basic" +venvPath = "." +venv = ".venv" +extraPaths = ["../.."] + +[tool.ruff] +target-version = "py311" +line-length = 100 diff --git a/modules/tool-server-data-ops/tests/__init__.py b/modules/tool-server-data-ops/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/tool-server-data-ops/tests/conftest.py b/modules/tool-server-data-ops/tests/conftest.py new file mode 100644 index 00000000..ede4441f --- /dev/null +++ b/modules/tool-server-data-ops/tests/conftest.py @@ -0,0 +1,22 @@ +"""Shared pytest configuration and fixtures for tool-server-data-ops tests.""" + +from __future__ import annotations + +from typing import Any + +import pytest + + +@pytest.fixture(autouse=True) +def _reset_auth_singleton() -> Any: + """Clear the auth module singleton and token cache before/after each test. + + Ensures the process-level _singleton_credential and _MODULE_CACHE do not + leak between tests, so patches of _make_cli_credential are effective and + cached tokens from one test don't pollute the next. + """ + from context_intelligence import auth as _auth_mod + + _auth_mod.reset() + yield + _auth_mod.reset() diff --git a/modules/tool-server-data-ops/tests/test_delete_session_tool.py b/modules/tool-server-data-ops/tests/test_delete_session_tool.py new file mode 100644 index 00000000..3ad81c46 --- /dev/null +++ b/modules/tool-server-data-ops/tests/test_delete_session_tool.py @@ -0,0 +1,523 @@ +"""Tests for DeleteSessionTool. + +Constructor: DeleteSessionTool(coordinator, resolver=None). Patch path is +amplifier_module_tool_server_data_ops.delete_session_tool. +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _make_coordinator(resolver: Any = None) -> MagicMock: + coordinator = MagicMock() + coordinator.config = {} + coordinator.get_capability = MagicMock(return_value=resolver) + return coordinator + + +def _make_hook_resolver( + server_url: str | None = "http://localhost:8080", + workspace: str = "test-workspace", + api_key: str = "test-api-key", +) -> MagicMock: + """Create a hook resolver mock (returned by get_capability).""" + resolver = MagicMock() + resolver.workspace = workspace + if server_url: + resolver.destinations = { + "default": SimpleNamespace(name="default", url=server_url, api_key=api_key or ""), + } + else: + resolver.destinations = {} + return resolver + + +def _make_hook_resolver_with_dests(destinations: dict) -> MagicMock: + """Hook resolver mock with a specific destinations dict.""" + resolver = MagicMock() + resolver.workspace = "test-workspace" + resolver.destinations = destinations + return resolver + + +def _make_mock_async_ci_client(return_value: Any = None): + """Return (mock_instance, mock_cls) for patching AsyncCIClient.""" + mock_instance = AsyncMock() + mock_instance.delete_session = AsyncMock( + return_value=return_value if return_value is not None else {"nodes_deleted": 0} + ) + mock_cls = MagicMock(return_value=mock_instance) + return mock_instance, mock_cls + + +def _make_tool_resolver(config: dict, coordinator: Any = None) -> Any: + """Build a real ToolConfigResolver from a config dict (for injection).""" + from context_intelligence.tool_resolver import ToolConfigResolver + + coord = coordinator or MagicMock() + coord.config = {} + return ToolConfigResolver(config, coord) + + +# --------------------------------------------------------------------------- +# TestDeleteSessionToolProtocol +# --------------------------------------------------------------------------- + + +class TestDeleteSessionToolProtocol: + """Tool protocol surface tests.""" + + def test_name_is_delete_session(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + tool = DeleteSessionTool(_make_coordinator()) + assert tool.name == "delete_session" + + def test_description_mentions_permanent(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + tool = DeleteSessionTool(_make_coordinator()) + assert "permanent" in tool.description.lower() + + def test_input_schema_returns_object_type(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + tool = DeleteSessionTool(_make_coordinator()) + assert tool.input_schema["type"] == "object" + + def test_input_schema_session_id_not_required_but_enforced_at_execute(self) -> None: + """`session_id` is NOT in the JSON-schema `required` list -- list_sources=true + calls legitimately omit it. execute() enforces the rule itself.""" + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + tool = DeleteSessionTool(_make_coordinator()) + assert "session_id" not in tool.input_schema["required"] + assert "session_id" in tool.input_schema["properties"] + + def test_input_schema_has_optional_source_and_list_sources(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + tool = DeleteSessionTool(_make_coordinator()) + props = tool.input_schema["properties"] + assert "source" in props + assert "list_sources" in props + assert "source" not in tool.input_schema["required"] + assert "list_sources" not in tool.input_schema["required"] + + async def test_execute_returns_tool_result(self) -> None: + from amplifier_core.models import ToolResult + + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + hook_resolver = _make_hook_resolver() + coordinator = _make_coordinator(resolver=hook_resolver) + tool = DeleteSessionTool(coordinator) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "abc"}) + + assert isinstance(result, ToolResult) + + +# --------------------------------------------------------------------------- +# TestListSources +# --------------------------------------------------------------------------- + + +class TestListSources: + async def test_list_sources_does_not_call_client(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + resolver = _make_tool_resolver( + {"sources": {"only": {"url": "http://only.example.com", "api_key": "k"}}} + ) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = DeleteSessionTool(coordinator, resolver) + + mock_cls = MagicMock() + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"list_sources": True}) + + assert result.success is True + assert result.output is not None + names = {e["name"] for e in result.output["connectable_set"]} + assert names == {"only"} + mock_cls.assert_not_called() + + +# --------------------------------------------------------------------------- +# TestDeleteSessionConstruction -- AsyncCIClient construction and delegation +# --------------------------------------------------------------------------- + + +class TestDeleteSessionConstruction: + """AsyncCIClient construction and delegation tests (mirrors GraphQueryTool).""" + + async def test_client_constructed_with_server_url_and_api_key(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000", api_key="my-key") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = DeleteSessionTool(coordinator) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + await tool.execute({"session_id": "abc"}) + + mock_cls.assert_called_once() + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs.get("server_url") == "http://ci-server:9000" + assert call_kwargs.get("api_key") == "my-key" + + async def test_session_id_forwarded_to_client_delete_session(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + hook_resolver = _make_hook_resolver() + coordinator = _make_coordinator(resolver=hook_resolver) + tool = DeleteSessionTool(coordinator) + + mock_instance, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + await tool.execute({"session_id": "the-session-id"}) + + mock_instance.delete_session.assert_called_once_with("the-session-id") + + async def test_result_forwarded_and_source_stamped(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = DeleteSessionTool(coordinator) + + expected = { + "root_id": "abc", + "nodes_deleted": 42, + "relationships_deleted": 10, + "blobs_deleted": 2, + } + _, mock_cls = _make_mock_async_ci_client(return_value=expected) + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "abc"}) + + assert result.success is True + assert result.output is not None + assert result.output["result"] == expected + assert result.output["source"] is not None + assert result.output["source"]["url"] == "http://ci-server:9000" + + +# --------------------------------------------------------------------------- +# TestDeleteSessionConfigFallback +# --------------------------------------------------------------------------- + + +class TestDeleteSessionConfigFallback: + async def test_capability_not_found_returns_configuration_error(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + coordinator = _make_coordinator(resolver=None) + tool = DeleteSessionTool(coordinator) + clean = {k: "" for k in os.environ if k.startswith("AMPLIFIER_CONTEXT_INTELLIGENCE_")} + with patch.dict(os.environ, clean): + result = await tool.execute({"session_id": "abc"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "configuration_error" + + async def test_missing_session_id_validation_error_carries_source(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + resolver = _make_tool_resolver( + {"sources": {"only": {"url": "http://only.example.com", "api_key": "k"}}} + ) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = DeleteSessionTool(coordinator, resolver) + + result = await tool.execute({}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "validation_error" + assert result.error["source"] == { + "name": "only", + "url": "http://only.example.com", + "origin": "source", + } + + +# --------------------------------------------------------------------------- +# TestDeleteSessionSourceSelection -- pool/selection + fail-loud ambiguity +# --------------------------------------------------------------------------- + + +class TestDeleteSessionSourceSelection: + """execute() with an explicit `source` -- matching / not matching / omitted-with-2+.""" + + def _two_source_config(self) -> dict: + return { + "sources": { + "alpha": {"url": "http://alpha.example.com", "api_key": "alpha-key"}, + "beta": {"url": "http://beta.example.com", "api_key": "beta-key"}, + } + } + + async def test_source_matching_name_selects_that_source(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + resolver = _make_tool_resolver(self._two_source_config()) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = DeleteSessionTool(coordinator, resolver) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "abc", "source": "beta"}) + + assert result.success is True + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs["server_url"] == "http://beta.example.com" + assert call_kwargs["api_key"] == "beta-key" + + async def test_source_not_matching_returns_unknown_source_error(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + resolver = _make_tool_resolver(self._two_source_config()) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = DeleteSessionTool(coordinator, resolver) + + result = await tool.execute({"session_id": "abc", "source": "gamma"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "unknown_source" + assert result.error["valid_sources"] == ["alpha", "beta"] + + async def test_source_omitted_with_two_configured_returns_ambiguous_error(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + resolver = _make_tool_resolver(self._two_source_config()) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = DeleteSessionTool(coordinator, resolver) + + result = await tool.execute({"session_id": "abc"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "ambiguous_source_selection" + assert result.error["valid_sources"] == ["alpha", "beta"] + + async def test_source_omitted_with_one_configured_still_succeeds(self) -> None: + """Safe to omit source with exactly one configured (backward compatible).""" + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + config = { + "sources": { + "default": {"url": "http://only.example.com", "api_key": "only-key"}, + } + } + resolver = _make_tool_resolver(config) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = DeleteSessionTool(coordinator, resolver) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "abc"}) + + assert result.success is True + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs["server_url"] == "http://only.example.com" + + async def test_selected_source_misconfigured_returns_source_misconfigured_error(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + config = { + "sources": { + "good": {"url": "http://good.example.com", "api_key": "gk"}, + "bad": {"url": "", "api_key": ""}, + } + } + resolver = _make_tool_resolver(config) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = DeleteSessionTool(coordinator, resolver) + + result = await tool.execute({"session_id": "abc", "source": "bad"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "source_misconfigured" + assert "bad" in result.error["message"] + + +# --------------------------------------------------------------------------- +# TestDeleteSessionServerErrors -- 404/409 surfaced as clear tool errors +# --------------------------------------------------------------------------- + + +class TestDeleteSessionServerErrors: + async def test_404_surfaces_as_clear_tool_error(self) -> None: + from context_intelligence.client import CIClientError + + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = DeleteSessionTool(coordinator) + + mock_instance = AsyncMock() + mock_instance.delete_session = AsyncMock( + side_effect=CIClientError( + "HTTP 404 from http://ci-server:9000/sessions/missing", + error_type="http_status", + url="http://ci-server:9000/sessions/missing", + status_code=404, + ) + ) + mock_cls = MagicMock(return_value=mock_instance) + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "missing"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "http_status" + assert result.error["status_code"] == 404 + assert "missing" in result.error["message"] + assert result.error["source"] is not None + + async def test_409_ambiguous_id_not_retried(self) -> None: + """A 409 with NO Retry-After hint is the ambiguous-id case: not + retryable, surfaced once as a clear tool error, never silently treated + as a completed delete.""" + from context_intelligence.client import CIClientError + + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = DeleteSessionTool(coordinator) + + mock_instance = AsyncMock() + mock_instance.delete_session = AsyncMock( + side_effect=CIClientError( + "HTTP 409 from http://ci-server:9000/sessions/dup", + error_type="http_status", + url="http://ci-server:9000/sessions/dup", + status_code=409, + retry_after=None, + ) + ) + mock_cls = MagicMock(return_value=mock_instance) + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "dup"}) + + assert result.success is False + assert result.error is not None + assert result.error["status_code"] == 409 + assert "ambiguous" in result.error["message"] + assert mock_instance.delete_session.await_count == 1 # never retried + + async def test_409_still_draining_retries_then_surfaces_precise_error(self) -> None: + """A 409 WITH a Retry-After hint (graph still draining) is retried a + bounded number of times honoring the hint; if it never clears, it + surfaces a precise 'still draining' error carrying retry_after.""" + from context_intelligence.client import CIClientError + + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = DeleteSessionTool(coordinator) + + mock_instance = AsyncMock() + mock_instance.delete_session = AsyncMock( + side_effect=CIClientError( + "HTTP 409 from http://ci-server:9000/sessions/live", + error_type="http_status", + url="http://ci-server:9000/sessions/live", + status_code=409, + retry_after=0, # 0s so the bounded backoff runs instantly + ) + ) + mock_cls = MagicMock(return_value=mock_instance) + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "live"}) + + assert result.success is False + assert result.error is not None + assert result.error["status_code"] == 409 + assert result.error["retry_after"] == 0 + assert "still draining" in result.error["message"] + # 1 initial attempt + 3 bounded retries + assert mock_instance.delete_session.await_count == 4 + + async def test_409_still_draining_then_succeeds_after_backoff(self) -> None: + """If the drain finishes mid-backoff, the bounded retry loop deletes + successfully instead of surfacing an error.""" + from context_intelligence.client import CIClientError + + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = DeleteSessionTool(coordinator) + + pending = CIClientError( + "HTTP 409 from http://ci-server:9000/sessions/live", + error_type="http_status", + url="http://ci-server:9000/sessions/live", + status_code=409, + retry_after=0, + ) + mock_instance = AsyncMock() + # 409 once, then a real delete result on the retry. + mock_instance.delete_session = AsyncMock( + side_effect=[pending, {"root_id": "live", "nodes_deleted": 3}] + ) + mock_cls = MagicMock(return_value=mock_instance) + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "live"}) + + assert result.success is True + assert result.output["result"] == {"root_id": "live", "nodes_deleted": 3} + assert mock_instance.delete_session.await_count == 2 diff --git a/modules/tool-server-data-ops/tests/test_module.py b/modules/tool-server-data-ops/tests/test_module.py new file mode 100644 index 00000000..9486beca --- /dev/null +++ b/modules/tool-server-data-ops/tests/test_module.py @@ -0,0 +1,331 @@ +"""Module-level contract tests for tool-server-data-ops. + +Tests for the merged three-tool module: mount registers all three tools +(session_summary, delete_session, whoami) from one call, the +ToolConfigResolver is shared (one instance, identical resolution), and +the lazy hook lookup stays lazy (not cached at mount time). whoami is +mounted here (imported from the shared context_intelligence library -- +see context_intelligence/whoami_tool.py) so the delete agent has +identity for ownership checks without needing tool-context-intelligence- +query (which would also bring graph_query). +""" + +from __future__ import annotations + +import inspect +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_coordinator(hook_resolver: Any = None) -> MagicMock: + """Coordinator whose get_capability returns hook_resolver.""" + coordinator = MagicMock() + coordinator.config = {} + coordinator.get_capability = MagicMock(return_value=hook_resolver) + coordinator.mount = AsyncMock() + return coordinator + + +def _make_dest(url: str, api_key: str = "") -> SimpleNamespace: + return SimpleNamespace(name="default", url=url, api_key=api_key) + + +def _make_hook_resolver(url: str | None = None, api_key: str = "") -> MagicMock: + """Minimal hook resolver mock with a destinations dict.""" + resolver = MagicMock() + resolver.workspace = "test-workspace" + if url: + resolver.destinations = {"default": _make_dest(url, api_key or "")} + else: + resolver.destinations = {} + return resolver + + +# --------------------------------------------------------------------------- +# TestModuleContract +# --------------------------------------------------------------------------- + + +class TestModuleContract: + """Module-level contract (type marker + mount signature).""" + + def test_module_type_is_tool(self) -> None: + from amplifier_module_tool_server_data_ops import __amplifier_module_type__ + + assert __amplifier_module_type__ == "tool" + + def test_mount_is_coroutine(self) -> None: + from amplifier_module_tool_server_data_ops import mount + + assert inspect.iscoroutinefunction(mount) + + def test_mount_signature_has_coordinator_and_config(self) -> None: + from amplifier_module_tool_server_data_ops import mount + + sig = inspect.signature(mount) + params = list(sig.parameters.keys()) + assert params[0] == "coordinator" + assert params[1] == "config" + + +# --------------------------------------------------------------------------- +# TestMountRegistersExactlyThreeTools +# --------------------------------------------------------------------------- + + +class TestMountRegistersExactlyThreeTools: + """mount() must register exactly three tools with distinct names.""" + + async def test_mount_registers_exactly_three_tools(self) -> None: + from amplifier_module_tool_server_data_ops import mount + + coordinator = _make_coordinator() + await mount(coordinator, config={}) + + assert coordinator.mount.call_count == 3 + + async def test_all_tool_calls_use_tools_category(self) -> None: + from amplifier_module_tool_server_data_ops import mount + + coordinator = _make_coordinator() + await mount(coordinator, config={}) + + for call in coordinator.mount.call_args_list: + assert call.args[0] == "tools" + + async def test_tool_names_are_session_summary_delete_session_and_whoami(self) -> None: + from amplifier_module_tool_server_data_ops import mount + + coordinator = _make_coordinator() + await mount(coordinator, config={}) + + registered_names = {call.kwargs["name"] for call in coordinator.mount.call_args_list} + assert registered_names == {"session_summary", "delete_session", "whoami"} + + async def test_mounted_tools_are_protocol_compliant(self) -> None: + from amplifier_module_tool_server_data_ops import mount + + coordinator = _make_coordinator() + await mount(coordinator, config={}) + + for call in coordinator.mount.call_args_list: + tool = call.args[1] + assert hasattr(tool, "name") + assert hasattr(tool, "description") + assert hasattr(tool, "input_schema") + assert hasattr(tool, "execute") + assert isinstance(tool.input_schema, dict) + assert inspect.iscoroutinefunction(tool.execute) + + async def test_mount_returns_none(self) -> None: + """mount() returns None -- the kernel ignores non-callable returns.""" + from amplifier_module_tool_server_data_ops import mount + + coordinator = _make_coordinator() + result = await mount(coordinator, config={}) + assert result is None + + async def test_mount_makes_no_register_capability_call(self) -> None: + from amplifier_module_tool_server_data_ops import mount + + coordinator = _make_coordinator() + await mount(coordinator, config={}) + + coordinator.register_capability.assert_not_called() + + +# --------------------------------------------------------------------------- +# TestSharedResolverInvariant +# --------------------------------------------------------------------------- + + +class TestSharedResolverInvariant: + """The ToolConfigResolver is shared: one instance, identical resolution.""" + + async def test_all_three_tools_have_same_resolver_instance(self) -> None: + """summary/delete/whoami._tool_resolver are the SAME object from mount().""" + from amplifier_module_tool_server_data_ops import mount + + coordinator = _make_coordinator() + await mount(coordinator, config={}) + + tools = {call.kwargs["name"]: call.args[1] for call in coordinator.mount.call_args_list} + summary = tools["session_summary"] + delete = tools["delete_session"] + whoami = tools["whoami"] + assert summary._tool_resolver is delete._tool_resolver + assert summary._tool_resolver is whoami._tool_resolver + + async def test_shared_resolver_consistency_same_url_and_api_key(self) -> None: + """All three tools resolve to the SAME (url, api_key) from sources. + + This is the load-bearing correctness invariant: with a shared resolver, + divergent read-endpoint config is structurally impossible. + """ + from context_intelligence.tool_resolver import resolve_query_connection + + from amplifier_module_tool_server_data_ops import mount + + config = { + "sources": { + "primary": {"url": "http://data-ops.example.com", "api_key": "shared" + "-key"}, + } + } + coordinator = _make_coordinator() + await mount(coordinator, config=config) + + tools = {call.kwargs["name"]: call.args[1] for call in coordinator.mount.call_args_list} + summary = tools["session_summary"] + delete = tools["delete_session"] + whoami = tools["whoami"] + + # Resolve using the shared resolver (no hook resolver needed for tier-1 hit) + summary_conn = resolve_query_connection(None, summary._tool_resolver) + delete_conn = resolve_query_connection(None, delete._tool_resolver) + whoami_conn = resolve_query_connection(None, whoami._tool_resolver) + + assert ( + summary_conn.url == delete_conn.url == whoami_conn.url == "http://data-ops.example.com" + ) + expected_key = "shared" + "-key" + assert summary_conn.api_key == expected_key + + +# --------------------------------------------------------------------------- +# TestLateMountTimingInvariant +# --------------------------------------------------------------------------- + + +class TestLateMountTimingInvariant: + """The lazy hook-resolver lookup must NOT be cached at mount() time. + + Catches any regression where the hook capability is fetched eagerly in + mount() rather than lazily in execute() (which would break when the hook + mounts later). + """ + + async def test_late_mount_session_summary_resolves_destination_after_hook_registers( + self, + ) -> None: + """Mount with NO hook -> register hook AFTER -> execute() sees the hook's destination.""" + from amplifier_module_tool_server_data_ops import mount + + # Step 1: mount with no hook registered + coordinator = _make_coordinator(hook_resolver=None) + await mount(coordinator, config={}) + tools = {call.kwargs["name"]: call.args[1] for call in coordinator.mount.call_args_list} + summary = tools["session_summary"] + + # Confirm hook resolver is None after mount (lazy, not fetched yet) + assert summary._hook_resolver is None + + # Step 2: register the hook resolver AFTER mount + hook_resolver = _make_hook_resolver(url="http://late-hook.example.com", api_key="late-key") + coordinator.get_capability.return_value = hook_resolver + + # Step 3: execute() must now see the late-registered hook destination + mock_client = MagicMock() + mock_client.session_summary = AsyncMock(return_value={"deletable": True}) + mock_cls = MagicMock(return_value=mock_client) + with patch( + "amplifier_module_tool_server_data_ops.session_summary_tool.AsyncCIClient", + mock_cls, + ): + result = await summary.execute({"session_id": "abc"}) + + assert result.success is True + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs["server_url"] == "http://late-hook.example.com" + assert call_kwargs["api_key"] == "late-key" + + async def test_late_mount_delete_session_resolves_destination_after_hook_registers( + self, + ) -> None: + """DeleteSessionTool: mount with no hook -> register hook -> execute sees destination.""" + from amplifier_module_tool_server_data_ops import mount + + coordinator = _make_coordinator(hook_resolver=None) + await mount(coordinator, config={}) + tools = {call.kwargs["name"]: call.args[1] for call in coordinator.mount.call_args_list} + delete = tools["delete_session"] + + assert delete._hook_resolver is None + + hook_resolver = _make_hook_resolver(url="http://late-hook.example.com", api_key="late-key") + coordinator.get_capability.return_value = hook_resolver + + mock_client = MagicMock() + mock_client.delete_session = AsyncMock(return_value={"nodes_deleted": 1}) + mock_cls = MagicMock(return_value=mock_client) + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + result = await delete.execute({"session_id": "abc"}) + + assert result.success is True + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs["server_url"] == "http://late-hook.example.com" + assert call_kwargs["api_key"] == "late-key" + + +# --------------------------------------------------------------------------- +# TestMountWithMisconfiguredSource +# --------------------------------------------------------------------------- + + +class TestMountWithMisconfiguredSource: + """mount() with one bad + one good source entry does not raise.""" + + async def test_mount_does_not_raise_with_one_bad_source(self) -> None: + from amplifier_module_tool_server_data_ops import mount + + config = { + "sources": { + "good": {"url": "http://good.example.com", "api_key": "gk"}, + "bad": {"url": "", "api_key": ""}, + } + } + coordinator = _make_coordinator() + # Must not raise. + result = await mount(coordinator, config=config) + assert result is None + + async def test_mount_registers_both_tools_with_one_bad_source(self) -> None: + from amplifier_module_tool_server_data_ops import mount + + config = { + "sources": { + "good": {"url": "http://good.example.com", "api_key": "gk"}, + "bad": {"url": "", "api_key": ""}, + } + } + coordinator = _make_coordinator() + await mount(coordinator, config=config) + + assert coordinator.mount.call_count == 3 + registered_names = {call.kwargs["name"] for call in coordinator.mount.call_args_list} + assert registered_names == {"session_summary", "delete_session", "whoami"} + + async def test_mount_logs_warning_with_one_bad_source(self, caplog: Any) -> None: + import logging + + from amplifier_module_tool_server_data_ops import mount + + config = { + "sources": { + "good": {"url": "http://good.example.com", "api_key": "gk"}, + "bad": {"url": "", "api_key": ""}, + } + } + coordinator = _make_coordinator() + with caplog.at_level(logging.WARNING, logger="context_intelligence.tool_resolver"): + await mount(coordinator, config=config) + + assert any("misconfigured" in r.message for r in caplog.records) + assert any("bad" in r.message for r in caplog.records) diff --git a/modules/tool-server-data-ops/tests/test_session_summary_tool.py b/modules/tool-server-data-ops/tests/test_session_summary_tool.py new file mode 100644 index 00000000..4b75c527 --- /dev/null +++ b/modules/tool-server-data-ops/tests/test_session_summary_tool.py @@ -0,0 +1,443 @@ +"""Tests for SessionSummaryTool. + +Constructor: SessionSummaryTool(coordinator, resolver=None). Patch path is +amplifier_module_tool_server_data_ops.session_summary_tool. +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _make_coordinator(resolver: Any = None) -> MagicMock: + coordinator = MagicMock() + coordinator.config = {} + coordinator.get_capability = MagicMock(return_value=resolver) + return coordinator + + +def _make_hook_resolver( + server_url: str | None = "http://localhost:8080", + workspace: str = "test-workspace", + api_key: str = "test-api-key", +) -> MagicMock: + """Create a hook resolver mock (returned by get_capability).""" + resolver = MagicMock() + resolver.workspace = workspace + if server_url: + resolver.destinations = { + "default": SimpleNamespace(name="default", url=server_url, api_key=api_key or ""), + } + else: + resolver.destinations = {} + return resolver + + +def _make_hook_resolver_with_dests(destinations: dict) -> MagicMock: + """Hook resolver mock with a specific destinations dict.""" + resolver = MagicMock() + resolver.workspace = "test-workspace" + resolver.destinations = destinations + return resolver + + +def _make_mock_async_ci_client(return_value: Any = None): + """Return (mock_instance, mock_cls) for patching AsyncCIClient.""" + mock_instance = AsyncMock() + mock_instance.session_summary = AsyncMock( + return_value=return_value if return_value is not None else {"deletable": True} + ) + mock_cls = MagicMock(return_value=mock_instance) + return mock_instance, mock_cls + + +def _make_tool_resolver(config: dict, coordinator: Any = None) -> Any: + """Build a real ToolConfigResolver from a config dict (for injection).""" + from context_intelligence.tool_resolver import ToolConfigResolver + + coord = coordinator or MagicMock() + coord.config = {} + return ToolConfigResolver(config, coord) + + +# --------------------------------------------------------------------------- +# TestSessionSummaryToolProtocol +# --------------------------------------------------------------------------- + + +class TestSessionSummaryToolProtocol: + """Tool protocol surface tests.""" + + def test_name_is_session_summary(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + tool = SessionSummaryTool(_make_coordinator()) + assert tool.name == "session_summary" + + def test_description_mentions_preview(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + tool = SessionSummaryTool(_make_coordinator()) + assert "preview" in tool.description.lower() + + def test_input_schema_returns_object_type(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + tool = SessionSummaryTool(_make_coordinator()) + assert tool.input_schema["type"] == "object" + + def test_input_schema_session_id_not_required_but_enforced_at_execute(self) -> None: + """`session_id` is NOT in the JSON-schema `required` list -- list_sources=true + calls legitimately omit it. execute() enforces the rule itself.""" + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + tool = SessionSummaryTool(_make_coordinator()) + assert "session_id" not in tool.input_schema["required"] + assert "session_id" in tool.input_schema["properties"] + + def test_input_schema_has_optional_source_and_list_sources(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + tool = SessionSummaryTool(_make_coordinator()) + props = tool.input_schema["properties"] + assert "source" in props + assert "list_sources" in props + assert "source" not in tool.input_schema["required"] + assert "list_sources" not in tool.input_schema["required"] + + async def test_execute_returns_tool_result(self) -> None: + from amplifier_core.models import ToolResult + + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + hook_resolver = _make_hook_resolver() + coordinator = _make_coordinator(resolver=hook_resolver) + tool = SessionSummaryTool(coordinator) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.session_summary_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "abc"}) + + assert isinstance(result, ToolResult) + + +# --------------------------------------------------------------------------- +# TestListSources +# --------------------------------------------------------------------------- + + +class TestListSources: + async def test_list_sources_does_not_call_client(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + resolver = _make_tool_resolver( + {"sources": {"only": {"url": "http://only.example.com", "api_key": "k"}}} + ) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = SessionSummaryTool(coordinator, resolver) + + mock_cls = MagicMock() + with patch( + "amplifier_module_tool_server_data_ops.session_summary_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"list_sources": True}) + + assert result.success is True + assert result.output is not None + names = {e["name"] for e in result.output["connectable_set"]} + assert names == {"only"} + mock_cls.assert_not_called() + + +# --------------------------------------------------------------------------- +# TestSessionSummaryConstruction -- AsyncCIClient construction and delegation +# --------------------------------------------------------------------------- + + +class TestSessionSummaryConstruction: + """AsyncCIClient construction and delegation tests (mirrors GraphQueryTool).""" + + async def test_client_constructed_with_server_url_and_api_key(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000", api_key="my-key") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = SessionSummaryTool(coordinator) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.session_summary_tool.AsyncCIClient", + mock_cls, + ): + await tool.execute({"session_id": "abc"}) + + mock_cls.assert_called_once() + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs.get("server_url") == "http://ci-server:9000" + assert call_kwargs.get("api_key") == "my-key" + + async def test_session_id_forwarded_to_client_session_summary(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + hook_resolver = _make_hook_resolver() + coordinator = _make_coordinator(resolver=hook_resolver) + tool = SessionSummaryTool(coordinator) + + mock_instance, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.session_summary_tool.AsyncCIClient", + mock_cls, + ): + await tool.execute({"session_id": "the-session-id"}) + + mock_instance.session_summary.assert_called_once_with("the-session-id") + + async def test_result_forwarded_and_source_stamped(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = SessionSummaryTool(coordinator) + + expected = {"created_by": "alice", "node_count": 10, "deletable": True} + _, mock_cls = _make_mock_async_ci_client(return_value=expected) + with patch( + "amplifier_module_tool_server_data_ops.session_summary_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "abc"}) + + assert result.success is True + assert result.output is not None + assert result.output["summary"] == expected + assert result.output["source"] is not None + assert result.output["source"]["url"] == "http://ci-server:9000" + + +# --------------------------------------------------------------------------- +# TestSessionSummaryConfigFallback +# --------------------------------------------------------------------------- + + +class TestSessionSummaryConfigFallback: + async def test_capability_not_found_returns_configuration_error(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + coordinator = _make_coordinator(resolver=None) + tool = SessionSummaryTool(coordinator) + clean = {k: "" for k in os.environ if k.startswith("AMPLIFIER_CONTEXT_INTELLIGENCE_")} + with patch.dict(os.environ, clean): + result = await tool.execute({"session_id": "abc"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "configuration_error" + + async def test_missing_session_id_validation_error_carries_source(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + resolver = _make_tool_resolver( + {"sources": {"only": {"url": "http://only.example.com", "api_key": "k"}}} + ) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = SessionSummaryTool(coordinator, resolver) + + result = await tool.execute({}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "validation_error" + assert result.error["source"] == { + "name": "only", + "url": "http://only.example.com", + "origin": "source", + } + + +# --------------------------------------------------------------------------- +# TestSessionSummarySourceSelection -- pool/selection + fail-loud ambiguity +# --------------------------------------------------------------------------- + + +class TestSessionSummarySourceSelection: + """execute() with an explicit `source` -- matching / not matching / omitted-with-2+.""" + + def _two_source_config(self) -> dict: + return { + "sources": { + "alpha": {"url": "http://alpha.example.com", "api_key": "alpha-key"}, + "beta": {"url": "http://beta.example.com", "api_key": "beta-key"}, + } + } + + async def test_source_matching_name_selects_that_source(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + resolver = _make_tool_resolver(self._two_source_config()) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = SessionSummaryTool(coordinator, resolver) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.session_summary_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "abc", "source": "beta"}) + + assert result.success is True + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs["server_url"] == "http://beta.example.com" + assert call_kwargs["api_key"] == "beta-key" + + async def test_source_not_matching_returns_unknown_source_error(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + resolver = _make_tool_resolver(self._two_source_config()) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = SessionSummaryTool(coordinator, resolver) + + result = await tool.execute({"session_id": "abc", "source": "gamma"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "unknown_source" + assert result.error["valid_sources"] == ["alpha", "beta"] + + async def test_source_omitted_with_two_configured_returns_ambiguous_error(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + resolver = _make_tool_resolver(self._two_source_config()) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = SessionSummaryTool(coordinator, resolver) + + result = await tool.execute({"session_id": "abc"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "ambiguous_source_selection" + assert result.error["valid_sources"] == ["alpha", "beta"] + + async def test_source_omitted_with_one_configured_still_succeeds(self) -> None: + """Safe to omit source with exactly one configured (backward compatible).""" + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + config = { + "sources": { + "default": {"url": "http://only.example.com", "api_key": "only-key"}, + } + } + resolver = _make_tool_resolver(config) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = SessionSummaryTool(coordinator, resolver) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.session_summary_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "abc"}) + + assert result.success is True + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs["server_url"] == "http://only.example.com" + + async def test_selected_source_misconfigured_returns_source_misconfigured_error(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + config = { + "sources": { + "good": {"url": "http://good.example.com", "api_key": "gk"}, + "bad": {"url": "", "api_key": ""}, + } + } + resolver = _make_tool_resolver(config) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = SessionSummaryTool(coordinator, resolver) + + result = await tool.execute({"session_id": "abc", "source": "bad"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "source_misconfigured" + assert "bad" in result.error["message"] + + +# --------------------------------------------------------------------------- +# TestSessionSummaryServerErrors -- 404/409 surfaced as clear tool errors +# --------------------------------------------------------------------------- + + +class TestSessionSummaryServerErrors: + async def test_404_surfaces_as_clear_tool_error(self) -> None: + from context_intelligence.client import CIClientError + + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = SessionSummaryTool(coordinator) + + mock_instance = AsyncMock() + mock_instance.session_summary = AsyncMock( + side_effect=CIClientError( + "HTTP 404 from http://ci-server:9000/sessions/missing/summary", + error_type="http_status", + url="http://ci-server:9000/sessions/missing/summary", + status_code=404, + ) + ) + mock_cls = MagicMock(return_value=mock_instance) + with patch( + "amplifier_module_tool_server_data_ops.session_summary_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "missing"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "http_status" + assert result.error["status_code"] == 404 + assert "missing" in result.error["message"] + assert result.error["source"] is not None + + async def test_409_surfaces_as_clear_tool_error(self) -> None: + from context_intelligence.client import CIClientError + + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = SessionSummaryTool(coordinator) + + mock_instance = AsyncMock() + mock_instance.session_summary = AsyncMock( + side_effect=CIClientError( + "HTTP 409 from http://ci-server:9000/sessions/live/summary", + error_type="http_status", + url="http://ci-server:9000/sessions/live/summary", + status_code=409, + ) + ) + mock_cls = MagicMock(return_value=mock_instance) + with patch( + "amplifier_module_tool_server_data_ops.session_summary_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "live"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "http_status" + assert result.error["status_code"] == 409 + assert "still receiving data" in result.error["message"] diff --git a/modules/tool-server-data-ops/uv.lock b/modules/tool-server-data-ops/uv.lock new file mode 100644 index 00000000..2d0a9ecd --- /dev/null +++ b/modules/tool-server-data-ops/uv.lock @@ -0,0 +1,923 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "amplifier-bundle-context-intelligence" +version = "0.1.3" +source = { git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=main#5011d481c6e5e1704967396aeca11fefcb905972" } +dependencies = [ + { name = "azure-identity" }, +] + +[[package]] +name = "amplifier-core" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tomli" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/cd/8b0b520bf0de741ea73e069aaf64aca28c9f4ce91a7b8b9239193a6c4c1b/amplifier_core-1.6.1-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c0f711d8408de78e53e5deddcb38b7240c5c1c497ca51eeaaeff23559b3d3c48", size = 8281633, upload-time = "2026-08-10T02:38:11.98Z" }, + { url = "https://files.pythonhosted.org/packages/14/83/f4fb297d87d35b9d74058da02bb153e12f7891ab62b3aaf7e0857f877798/amplifier_core-1.6.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b08f37e2c0b1611349a0e25d5bf9bfdfae3afcee35488f8e26bba1cdd400503b", size = 7366930, upload-time = "2026-08-10T02:38:14.105Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/5eb9cecf92d8053c5e6d46ad9668c3ed3558d5423845c1dced1f266b2a38/amplifier_core-1.6.1-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ebf7e3993c76ea506e70ac7844b286c3ba2e9127b3bcb350fa4fcd2dcdbd38d", size = 7659512, upload-time = "2026-08-10T02:38:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/121f054e3d079dc33d83f3d8ba9af50fd9f7694c3e2ba3d7d23d7c157d48/amplifier_core-1.6.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c957cd0671d2a003f2c8f7d6a41bd6e808f97d183c57b97e7700bf4c912621d", size = 8678425, upload-time = "2026-08-10T02:38:18.243Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/bfc217f4a9ed2d033995fc59847f1fee2e1b17130632fcb0e0981a1a311b/amplifier_core-1.6.1-cp311-abi3-win_amd64.whl", hash = "sha256:50c80bcfa1f6efe769b19e7af18c925024c7553d4db08880727241709dd44eae", size = 8976601, upload-time = "2026-08-10T02:38:20.505Z" }, + { url = "https://files.pythonhosted.org/packages/a5/14/5f330452c92c6c5d35c51ad5311301949ce5db4d1a1a901456f3ee43eaac/amplifier_core-1.6.1-cp311-abi3-win_arm64.whl", hash = "sha256:cd8b617f132cf5d1ca3e5187d5f831d1f2a508bb40d07b2ab1085961bcb9e1a9", size = 7744837, upload-time = "2026-08-10T02:38:22.562Z" }, +] + +[[package]] +name = "amplifier-module-tool-server-data-ops" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "amplifier-bundle-context-intelligence" }, + { name = "httpx" }, + { name = "idna" }, +] + +[package.dev-dependencies] +dev = [ + { name = "amplifier-core" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "amplifier-bundle-context-intelligence", git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=main" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "idna", specifier = ">=3.15" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "amplifier-core", specifier = ">=1.6.0" }, + { name = "pyright", specifier = ">=1.1.411" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "ruff", specifier = ">=0.14" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "azure-core" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042, upload-time = "2026-05-07T23:30:54.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/db/325c6d7312d2200251c52323878281045aaffcb5586612296484e4280eaa/azure_core-1.41.0-py3-none-any.whl", hash = "sha256:522b4011e8180b1a3dcd2024396a4e7fe9ac37fb8597db47163d230b5efe892d", size = 220920, upload-time = "2026-05-07T23:30:56.357Z" }, +] + +[[package]] +name = "azure-identity" +version = "1.25.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" }, + { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" }, + { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" }, + { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "msal" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/1f/10f9d47a63d3a2e61b2c43e15bee6b95682aab827018f9a1b97a80787e25/msal-1.38.0.tar.gz", hash = "sha256:4f10ff1257bacfd1781f22e85bd2b8d43ad1b490f3b6aafd7906671cadedd464", size = 203411, upload-time = "2026-08-24T10:22:46.053Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ca/d768f77a27d81ed0a6884f2458f8613c31c79b2eb95defbeca2273fd0754/msal-1.38.0-py3-none-any.whl", hash = "sha256:765b9b98b6aa380ee8b8f1c75636e08863edaf0a953498955bd668650dde5d49", size = 131057, upload-time = "2026-08-24T10:22:47.485Z" }, +] + +[[package]] +name = "msal-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyright" +version = "1.1.411" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, + { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, + { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, + { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, + { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, + { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, + { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, + { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] diff --git a/pyproject.toml b/pyproject.toml index c24f4cd6..192133b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,12 @@ dev = [ "httpx>=0.25", "idna>=3.15", "PyYAML>=6.0", + # context_intelligence/whoami_tool.py imports amplifier_core.models.ToolResult + # (WhoamiTool is mounted from two tool modules -- see whoami_tool.py's own + # docstring -- and lives here in the shared library, not in either module's + # own package, so it needs amplifier-core as a dev dep for pyright/pytest + # the same way the module pyproject.toml files already do). + "amplifier-core>=1.6.0", ] [tool.pytest.ini_options] @@ -49,6 +55,8 @@ pythonVersion = "3.11" typeCheckingMode = "basic" include = ["context_intelligence", "tests"] extraPaths = ["."] +venvPath = "." +venv = ".venv" [tool.ruff] target-version = "py311" diff --git a/skills/context-intelligence-graph-query/SKILL.md b/skills/context-intelligence-graph-query/SKILL.md index 206e7712..b9a76015 100644 --- a/skills/context-intelligence-graph-query/SKILL.md +++ b/skills/context-intelligence-graph-query/SKILL.md @@ -179,6 +179,31 @@ ORDER BY sessions DESC LIMIT 25 ``` +### Resolving "my" — use `whoami`, don't guess + +`created_by` scopes by *who produced the data*, but the graph never tells you +who "you" are. When a request is scoped to the acting user themselves — +"my sessions", "sessions I created", "what have I been working on" — call the +`whoami` tool for the relevant server before filtering on `created_by`: + +```python +whoami() # default endpoint +whoami(source="prod") # a specific configured endpoint +``` + +It returns `{"contributor_id": "", "source": {...}}`. Use +`contributor_id` as the value to match against `created_by`: + +```cypher +MATCH (s:Session {workspace: $workspace, created_by: $contributor_id}) +RETURN count(s) AS my_sessions +``` + +**Null case.** A null `contributor_id` means auth is disabled (or otherwise +unknown) on that server — there is no acting-user identity to filter by. Do +**not** guess or fall back to some other field. Ask the user how they want to +scope the query instead (e.g. by name, by workspace, or "show everyone's"). + --- ## Section 3 — Traps: Where the Shapes Lie About Their Meaning diff --git a/skills/context-intelligence-server-data-ops/SKILL.md b/skills/context-intelligence-server-data-ops/SKILL.md new file mode 100644 index 00000000..ab198cce --- /dev/null +++ b/skills/context-intelligence-server-data-ops/SKILL.md @@ -0,0 +1,296 @@ +--- +name: context-intelligence-server-data-ops +version: 1.0.0 +description: Exact step order, wording, and the "session details" block format for the delete flows the server-data-ops agent drives — delete the current session, clean up every session from the current working directory, find-then-delete a session by description, and delete a session someone else created. +license: MIT +--- + +# Context Intelligence Server Data Ops + +Step-by-step procedure for deleting Context Intelligence session data from a server, so +the delete flows run the same way every time instead of being improvised fresh. + +**This is a direct, interactive conversation with the user** — every decision-critical +step (preview, offer, impact, confirmation) is shown to them directly, in your own +message, never summarized away by delegation. + +## When to Use + +Load whenever the `server-data-ops` agent is about to run a delete flow: the current +session, every session from the current working directory, a session found by +description, or a session created by someone else. + +## When NOT to Use + +- A plain `session_summary` preview with no delete on the table doesn't need this. +- Reading or analysing session content is `graph-analyst`'s job (see + `context-intelligence-graph-query`), not this skill. + +--- + +## Tools + +- **`session_summary`** — read-only preview. Returns `{source: {name, url, origin}, + summary: {...}}`. `summary` fields (the server's `DeletionPreview`): + + | Field | Meaning | + |---|---| + | `root_id` | The graph's root session id | + | `session_ids` | Every session id in that graph (root + descendants) | + | `node_count`, `edge_count`, `blob_count` | Totals for the whole graph | + | `created_by` | Who created the root session | + | `started_at`, `last_change` | ISO-8601 timestamps | + | `subsession_count` | Sessions under the root | + | `workspace`, `working_dir` | Where it ran | + | `deletable` | `false` if anything in the graph is still receiving data | + | `pending_sessions` | Session ids still receiving data, if any | + +- **`delete_session`** — the real, permanent delete. Returns `{source: {...}, result: + {root_id, session_count, nodes_deleted, relationships_deleted, blobs_deleted, + queue_sessions_cleaned}}`. +- **`whoami`** — read-only identity lookup. Returns `{contributor_id, source: {name, + url, origin}}` for the server you call it against. Used to compare against a + session's `created_by` (see Ownership Check). + +All three accept `source` (name a server) and `list_sources: true` (discover the +connectable set without acting). None takes a workspace — you always address a +session by id. + +- **`todo`** — the standard todo-list tool. Used only in the folder cleanup, one item + per candidate root session found, so a bulk cleanup never silently skips one. + +A lockdown hook denies this agent write_file, edit_file, apply_patch, and any direct +graph-query tool — it guides the user through settings edits instead of making them, +and delegates all search and narrative work to `graph-analyst`. + +--- + +## Key Concepts + +**Current session id.** Comes from Amplifier's own runtime context — the `Session +ID` field injected into your status context every turn. For any "this session" / "my +current session" request, that value IS the session to act on. Don't ask the user +for an id, and a typed id never replaces it — resolve from context regardless. Only +ask directly if context genuinely has no `Session ID`. + +**Current working directory (for the folder cleanup).** Comes from the same runtime +context — the `Working directory` field injected into your status context every +turn, resolved the same way as the session id above. For any "this folder" / "this +working directory" / "uploaded from here" request, that value is the directory to +search — not a single session id. Don't ask unless context genuinely has none. + +**Current user identity (for ownership).** Never read from context and never guess. +Call `whoami` for the *same server* the session in question is on, and use its +`contributor_id` as the one reference identity for the comparison (see Ownership +Check). + +**When the folder exclusion applies.** Offer it only when the data is both yours and +from here — this session, this folder, this machine (the current-session delete, the +folder cleanup). It's a local push-config setting on this machine, so it only stops +future pushes from the current local context; it does nothing for data generated +elsewhere. Don't offer it for a session found by topic/description (the +find-by-description delete) or one that isn't yours (see Ownership Check). + +## The "Session Details" Block + +Use this exact shape for any candidate or confirmed target (the find-by-description +candidate list; the pre-delete confirmation in any flow): + +``` +Session + Created by: + Started: + Last updated: + Sub-sessions: + Working dir: + Server: () + Still live: <"yes — cannot delete yet" if not deletable, else "no"> + Summary: +``` + +Fill every field from a real `session_summary` call. Never fabricate a value you +didn't receive. + +**Summary line.** Not returned by the server, and not built by this agent — it comes +from `graph-analyst`, delegated to alongside the search (see the search step in +"Find a Session by Description, Then Delete"). It returns a short synthesized +overview of what the session was about, built from that session's **root**-session +prompts only (never subsessions). This must be a synthesis, never a raw or verbatim +prompt quote, and never a from-memory guess. If `graph-analyst` can't produce one, +use "not available." + +--- + +## Delete the Current Session + +Applies whenever the request refers to the user's own current session ("my current +session," "this session," "the session I'm in") — even if the user also supplies a +session id; a supplied id doesn't downgrade it out of this case or replace the runtime +id. Route to **Clean Up Everything Pushed From This Working Directory** instead when +the request is about the whole working directory, not just the current session +("this folder," "this working directory," "uploaded from here"). Route to **Find a +Session by Description, Then Delete** when the request names or searches for some +*other* session. + +1. **Resolve.** Take the current session id from runtime context (see Key Concepts). + Don't ask the user for an id. +2. **Prove.** Call `session_summary(session_id=, list_sources=true)` and show + the user: `root_id`, `created_by` (confirmed against `whoami` for the same server), + `working_dir`, and `last_change` (flag if under a minute old — "may still be live"). + Note every server the session exists on, from `list_sources`. If it 404s on every + server, stop and tell the user plainly — never delete against an unresolved session. +3. **Offer the folder exclusion to the user directly, and wait for their answer**, + before anything else proceeds. This is the destination **push filter** on the + session's `working_dir`, not the delete scope — deletion always removes the whole + session graph regardless of this setting. Show the setting + `overrides.hook-context-intelligence.config.destinations..exclude` in + `~/.amplifier/settings.yaml` — a list of gitignore-style patterns matched against a + session's `working_dir`; adding one for the current folder stops that destination + being selected. The agent has no filesystem tool, so it + shows the setting in its own message and offers to guide the user through applying + it — never edits the file itself. Make this offer every time, regardless of whether + you can confirm the folder is currently included. Wait for their answer, confirm + whether they applied it, then move on. + + **Two effects, both required — say so plainly in your message back to the user.** + Writing the pattern into `settings.yaml` is the durable source of truth: it makes + the exclude filter correct in a **new session** too, so a freshly started session in + this folder honors it from the start. To also stop the **already-running** session + (and any sub-session it spawns afterward) without a restart, the + `set_ingestion_filters` tool is run in the **root session** — it re-reads + `settings.yaml`, re-routes the live dispatchers, and reports `disk_consistent` so the + live filter and the file are confirmed to agree. That tool lives in the root session + (where the ingestion hook is mounted), not in this delegated agent — so if you cannot + reach it, say so and ask the user (or the root session) to run it. Close the loop + with a message of the shape: "Exclude saved to settings.yaml — **make sure the + exclude filter is correct in a new session too** — and applied live to the current + session; both the current session and any new session in this folder now stop + pushing to ``." +4. **State the impact.** The whole graph — the session plus every descendant (forks, + sub-sessions, delegated children) — plus its blobs and queue records, permanently, + on the server(s) found in step 2. Shared nodes are kept; there is no undo. +5. **Confirm.** Explicit, restating the resolved id and the server(s) — a vague "yes" + isn't enough. +6. **Delete, then verify, on every server** (see Multi-Server Handling). + +--- + +## Find a Session by Description, Then Delete + +1. Take the user's description (topic, content, date range, sometimes a + server/workspace). +2. **Search + narrate.** For any non-trivial criteria (topic, content, date, workspace), + delegate to `graph-analyst`. It runs the search AND returns, for each candidate, both + the key facts and a short synthesized overview (see "Summary line" above) — you never + query the graph or build the narrative yourself. This is a data-fetch delegation, not + a hand-off of the conversation — the results come back to you and you keep driving + the flow. Skip the delegation only for a trivial direct lookup — the user names an + exact session id — and call `session_summary` on it directly. Cap the candidate set + to a handful before per-candidate work. +3. Build a Session Details Block per candidate, using the overview `graph-analyst` + returned (or "not available"), and present them; the user picks one. +4. Run the ownership check on the chosen session. +5. Re-run `session_summary` on the chosen id right before delete (a fresh preview, in + case anything changed since step 2). +6. Confirm explicitly — id, counts, server(s) — then delete and verify on every server + (see Multi-Server Handling). + +--- + +## Clean Up Everything Pushed From This Working Directory (Yours, From Here) + +This is a find-by-criteria cleanup, not a current-session delete. The +current-session delete's defining trait is the **current** session, resolved from +runtime; this cleanup finds sessions by **criteria** — `working_dir` = this folder +AND `created_by` = you — the same find-by-criteria shape as the find-by-description +delete. The only thing it borrows from the current-session delete is the +folder-exclusion offer, because it's your own folder being pushed. + +Applies when the user is worried that data from sessions run in THIS folder was +pushed and should not have been — "I think I uploaded data from sessions in this +folder, I want to delete it," "things from this working directory should never +have been pushed." Route here instead of the current-session delete when the +request is about the *folder*, not a single session; route here instead of the +plain find-by-description delete when the criteria is specifically "this folder + +mine" rather than a topic/date/server description. + +1. **Apply the folder exclusion first**, before finding or deleting anything. + Resolve the working directory from runtime context (see Key Concepts), the + same way the current-session delete resolves the current session id. Offer the + setting `overrides.hook-context-intelligence.config.destinations..exclude` + in `~/.amplifier/settings.yaml` — the same folder-exclusion offer described in + the current-session delete's step 3 — show it in your own message, guide the + user through applying it (you never edit the file yourself), and confirm it's + applied before moving on: while the folder is still in scope, continued + ingestion would keep re-creating the very data you are about to delete. Apply it + with BOTH effects from step 3 — persisted to `settings.yaml` (so the exclude + filter is correct in a new session too) AND made live via `set_ingestion_filters` in + the root session (so the current running session, and any sub-session it spawns + afterward, stop immediately without a restart) — before you enumerate or delete. +2. **Run the S2 search, by criteria (this folder + mine).** Delegate to + `graph-analyst` to enumerate every **root** session (never subsessions) + whose `working_dir` matches the resolved directory AND `created_by` is you, + checking every configured server (all-servers completeness applies to the + search too, not only the deletes). It returns the candidate root sessions, + their key facts, and a short synthesized overview for each — same as the + find-by-description delete's search step. +3. **Propose the list.** For each candidate, build a Session Details Block + using the overview `graph-analyst` returned (see "Summary line" above), + present it, and add one item to a todo list (the `todo` tool) per session + found — so every session is tracked and none is silently skipped. +4. **Delete all.** Walk the todo list one session at a time. For each: re-run + `session_summary` (a fresh preview), run the ownership check (a + session in this folder may not be the user's own), state the impact, + confirm explicitly, delete, and verify on every server it is on (see + Multi-Server Handling) — then mark that todo item done. Never mark an item + done before its delete is verified. + +--- + +## Ownership Check (Before Deleting Any Found or Named Session) + +Runs inside any of the delete flows above — the current-session delete, the +find-by-description delete, or the folder cleanup — after the preview and before +the delete confirmation. + +1. From the preview, note the session's `created_by`. +2. Call `whoami` for the *same server* the session is on. Read its `contributor_id`. +3. Compare: + - **Match** → the user's own session. No warning; continue normally. + - **Differ** → tell the user directly, in your own visible message, that it was + created by ``, not them, and wait for a *separate*, explicit + confirmation before deleting. + - **`contributor_id` is null** (auth disabled or unresolvable) → say you can't + confirm ownership and ask the user directly, waiting for their answer. Don't treat + null as a mismatch. + +Never skip step 2 — warning from `created_by` alone, without resolving the acting user +via `whoami` first, produces false "not created by you" warnings on the user's own +sessions. + +--- + +## Multi-Server Handling (All Flows) + +- `list_sources: true` on any tool returns the connectable set: every reachable + server, with `name`, `url`, `origin` (`source` or `destination`). +- `source=` addresses one server. Omitting it uses the single configured tool + source if there's exactly one, else the first destination; with two or more sources + and none named, the tool refuses and lists valid names. +- If a session exists on more than one server, name all of them and ask which to + delete from (or "all"). +- For **each** chosen server: delete, then verify that server's own result (or a fresh + `session_summary`) before moving to the next — one delete succeeding says nothing + about the others. +- Report exactly what was removed and from where. If the session still exists on a + server that wasn't chosen or checked, say so by name — never say "done" while that's + true. + +## Errors to Expect + +- **404** — the session id is unknown to that server. Say so; check for a typo or a + different server. +- **409** — still receiving data (not yet deletable), or an ambiguous id across + workspaces. Never force or retry aggressively — tell the user it's still live. +- **`ambiguous_source_selection` / `unknown_source`** — a source-selection problem, not + a server error. Call with `list_sources: true` and ask the user to pick a name. diff --git a/tests/test_client.py b/tests/test_client.py index 6adbf54c..fd245a15 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -360,6 +360,262 @@ def test_fetch_blob_includes_authorization_header(self): assert headers.get("Authorization") == "Bearer blobkey" +class TestCIClientSessionSummary: + """CIClient.session_summary() must GET /sessions/{id}/summary and return a dict.""" + + def test_session_summary_returns_dict(self): + """session_summary() returns the parsed summary dict from the server.""" + from context_intelligence.client import CIClient + + client = CIClient("http://localhost:8000", "key") + mock_response = { + "created_by": "alice", + "node_count": 10, + "edge_count": 5, + "blob_count": 2, + "deletable": True, + } + + with patch("context_intelligence.client._http_get_strict") as mock_get: + mock_get.return_value = mock_response + result = client.session_summary("session1") + + assert result == mock_response + + def test_session_summary_calls_correct_url(self): + """session_summary() calls GET /sessions/{session_id}/summary.""" + from context_intelligence.client import CIClient + + client = CIClient("http://localhost:8000", "key") + + with patch("context_intelligence.client._http_get_strict") as mock_get: + mock_get.return_value = {} + client.session_summary("my-session") + + call_args = mock_get.call_args + url = call_args[0][0] if call_args[0] else call_args[1]["url"] + assert url == "http://localhost:8000/sessions/my-session/summary" + + def test_session_summary_includes_authorization_header(self): + """session_summary() sends Authorization: [REDACTED:SECRET]""" + from context_intelligence.client import CIClient + + client = CIClient("http://localhost:8000", "secretkey") + + with patch("context_intelligence.client._http_get_strict") as mock_get: + mock_get.return_value = {} + client.session_summary("my-session") + + call_args = mock_get.call_args + headers = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("headers", {}) + assert headers.get("Authorization") == "Bearer secretkey" + + def test_session_summary_propagates_ciclienterror_404(self): + """A 404 (unknown session) must not be swallowed -- it propagates as CIClientError.""" + from context_intelligence.client import CIClient, CIClientError + + client = CIClient("http://localhost:8000", "key") + + with patch("context_intelligence.client._http_get_strict") as mock_get: + mock_get.side_effect = CIClientError( + "HTTP 404 from http://localhost:8000/sessions/missing/summary", + error_type="http_status", + url="http://localhost:8000/sessions/missing/summary", + status_code=404, + ) + with pytest.raises(CIClientError) as excinfo: + client.session_summary("missing") + + assert excinfo.value.error_type == "http_status" + assert excinfo.value.status_code == 404 + + def test_session_summary_propagates_ciclienterror_409(self): + """A 409 (still receiving data / ambiguous id) must propagate as CIClientError.""" + from context_intelligence.client import CIClient, CIClientError + + client = CIClient("http://localhost:8000", "key") + + with patch("context_intelligence.client._http_get_strict") as mock_get: + mock_get.side_effect = CIClientError( + "HTTP 409 from http://localhost:8000/sessions/live/summary", + error_type="http_status", + url="http://localhost:8000/sessions/live/summary", + status_code=409, + ) + with pytest.raises(CIClientError) as excinfo: + client.session_summary("live") + + assert excinfo.value.error_type == "http_status" + assert excinfo.value.status_code == 409 + + +class TestCIClientWhoami: + """CIClient.whoami() must GET /whoami and return a dict.""" + + def test_whoami_returns_dict(self): + """whoami() returns the parsed identity dict from the server.""" + from context_intelligence.client import CIClient + + client = CIClient("http://localhost:8000", "key") + mock_response = {"contributor_id": "octocat"} + + with patch("context_intelligence.client._http_get_strict") as mock_get: + mock_get.return_value = mock_response + result = client.whoami() + + assert result == mock_response + + def test_whoami_calls_correct_url(self): + """whoami() calls GET /whoami.""" + from context_intelligence.client import CIClient + + client = CIClient("http://localhost:8000", "key") + + with patch("context_intelligence.client._http_get_strict") as mock_get: + mock_get.return_value = {} + client.whoami() + + call_args = mock_get.call_args + url = call_args[0][0] if call_args[0] else call_args[1]["url"] + assert url == "http://localhost:8000/whoami" + + def test_whoami_includes_authorization_header(self): + """whoami() sends Authorization: Bearer .""" + from context_intelligence.client import CIClient + + client = CIClient("http://localhost:8000", "secretkey") + + with patch("context_intelligence.client._http_get_strict") as mock_get: + mock_get.return_value = {} + client.whoami() + + call_args = mock_get.call_args + headers = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("headers", {}) + assert headers.get("Authorization") == "Bearer secretkey" + + def test_whoami_returns_null_contributor_id_when_auth_disabled(self): + """A server with auth disabled returns contributor_id: null -- passed through.""" + from context_intelligence.client import CIClient + + client = CIClient("http://localhost:8000", "key") + + with patch("context_intelligence.client._http_get_strict") as mock_get: + mock_get.return_value = {"contributor_id": None} + result = client.whoami() + + assert result == {"contributor_id": None} + + def test_whoami_propagates_ciclienterror(self): + """A genuine transport/HTTP failure must not be swallowed -- it propagates.""" + from context_intelligence.client import CIClient, CIClientError + + client = CIClient("http://localhost:8000", "key") + + with patch("context_intelligence.client._http_get_strict") as mock_get: + mock_get.side_effect = CIClientError( + "HTTP 500 from http://localhost:8000/whoami", + error_type="http_status", + url="http://localhost:8000/whoami", + status_code=500, + ) + with pytest.raises(CIClientError) as excinfo: + client.whoami() + + assert excinfo.value.error_type == "http_status" + assert excinfo.value.status_code == 500 + + +class TestCIClientDeleteSession: + """CIClient.delete_session() must DELETE /sessions/{id} and return a dict.""" + + def test_delete_session_returns_dict(self): + """delete_session() returns the parsed result-counts dict from the server.""" + from context_intelligence.client import CIClient + + client = CIClient("http://localhost:8000", "key") + mock_response = { + "root_id": "session1", + "session_count": 3, + "nodes_deleted": 42, + "relationships_deleted": 10, + "blobs_deleted": 2, + "queue_sessions_cleaned": 1, + } + + with patch("context_intelligence.client._http_delete_strict") as mock_delete: + mock_delete.return_value = mock_response + result = client.delete_session("session1") + + assert result == mock_response + + def test_delete_session_calls_correct_url(self): + """delete_session() calls DELETE /sessions/{session_id} (no query string/body).""" + from context_intelligence.client import CIClient + + client = CIClient("http://localhost:8000", "key") + + with patch("context_intelligence.client._http_delete_strict") as mock_delete: + mock_delete.return_value = {} + client.delete_session("my-session") + + call_args = mock_delete.call_args + url = call_args[0][0] if call_args[0] else call_args[1]["url"] + assert url == "http://localhost:8000/sessions/my-session" + + def test_delete_session_includes_authorization_header(self): + """delete_session() sends Authorization: [REDACTED:SECRET]""" + from context_intelligence.client import CIClient + + client = CIClient("http://localhost:8000", "secretkey") + + with patch("context_intelligence.client._http_delete_strict") as mock_delete: + mock_delete.return_value = {} + client.delete_session("my-session") + + call_args = mock_delete.call_args + headers = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("headers", {}) + assert headers.get("Authorization") == "Bearer secretkey" + + def test_delete_session_propagates_ciclienterror_404(self): + """A 404 (unknown session) must not be swallowed -- it propagates as CIClientError.""" + from context_intelligence.client import CIClient, CIClientError + + client = CIClient("http://localhost:8000", "key") + + with patch("context_intelligence.client._http_delete_strict") as mock_delete: + mock_delete.side_effect = CIClientError( + "HTTP 404 from http://localhost:8000/sessions/missing", + error_type="http_status", + url="http://localhost:8000/sessions/missing", + status_code=404, + ) + with pytest.raises(CIClientError) as excinfo: + client.delete_session("missing") + + assert excinfo.value.error_type == "http_status" + assert excinfo.value.status_code == 404 + + def test_delete_session_propagates_ciclienterror_409(self): + """A 409 (still receiving data / ambiguous id) must propagate as CIClientError, + never silently treated as a completed delete.""" + from context_intelligence.client import CIClient, CIClientError + + client = CIClient("http://localhost:8000", "key") + + with patch("context_intelligence.client._http_delete_strict") as mock_delete: + mock_delete.side_effect = CIClientError( + "HTTP 409 from http://localhost:8000/sessions/live", + error_type="http_status", + url="http://localhost:8000/sessions/live", + status_code=409, + ) + with pytest.raises(CIClientError) as excinfo: + client.delete_session("live") + + assert excinfo.value.error_type == "http_status" + assert excinfo.value.status_code == 409 + + class TestCIClientHealthCheck: """CIClient.health_check() must use cypher() to run a count query and return dict.""" @@ -418,6 +674,217 @@ def test_health_check_returns_zero_count_on_empty_result(self): assert result["session_count"] == 0 +class _GenericBehavior: + """Mutable control block read by the handler on every request (any path).""" + + def __init__(self) -> None: + self.status_code: int = 200 + self.body: bytes = b"{}" + self.paths: list[str] = [] # every request path the server was asked for + + +def _make_generic_handler(behavior: "_GenericBehavior"): + from http.server import BaseHTTPRequestHandler + + class _Handler(BaseHTTPRequestHandler): + def _respond(self): + behavior.paths.append(self.path) + self.send_response(behavior.status_code) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(behavior.body) + + def do_GET(self): # noqa: N802 + self._respond() + + def do_DELETE(self): # noqa: N802 + self._respond() + + def log_message(self, *args): # silence server logs + pass + + return _Handler + + +def _start_generic_server(behavior: "_GenericBehavior"): + import threading + from http.server import ThreadingHTTPServer + + port = _find_free_port() + server = ThreadingHTTPServer(("127.0.0.1", port), _make_generic_handler(behavior)) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server, f"http://127.0.0.1:{port}" + + +class TestCIClientErrorClassification: + """error_type classification must be correct: auth_error vs decode_error vs http_status. + + Regression guard: ``ApiKeyAuth.headers()`` raising ``ValueError`` for an + unusable/redacted credential must classify as ``auth_error`` -- it must + NEVER be caught by the SAME ``except (ValueError, json.JSONDecodeError)`` + handler used for a genuinely malformed JSON response, which previously + misreported a healthy 200 response (or, before any request was even sent) + as "malformed JSON from {url}". + """ + + # -- (a) unusable credential -> auth_error, NEVER decode_error ---------- + + @pytest.mark.parametrize("bad_key", ["", "[REDACTED]"]) + def test_session_summary_unusable_credential_is_auth_error(self, bad_key): + from context_intelligence.client import CIClient, CIClientError + + client = CIClient("http://localhost:8000", bad_key) + + with patch("context_intelligence.client._http_get_strict") as mock_get: + with pytest.raises(CIClientError) as excinfo: + client.session_summary("session1") + + assert excinfo.value.error_type == "auth_error" + mock_get.assert_not_called() + + @pytest.mark.parametrize("bad_key", ["", "[REDACTED]"]) + def test_delete_session_unusable_credential_is_auth_error(self, bad_key): + from context_intelligence.client import CIClient, CIClientError + + client = CIClient("http://localhost:8000", bad_key) + + with patch("context_intelligence.client._http_delete_strict") as mock_delete: + with pytest.raises(CIClientError) as excinfo: + client.delete_session("session1") + + assert excinfo.value.error_type == "auth_error" + mock_delete.assert_not_called() + + @pytest.mark.parametrize("bad_key", ["", "[REDACTED]"]) + def test_whoami_unusable_credential_is_auth_error(self, bad_key): + from context_intelligence.client import CIClient, CIClientError + + client = CIClient("http://localhost:8000", bad_key) + + with patch("context_intelligence.client._http_get_strict") as mock_get: + with pytest.raises(CIClientError) as excinfo: + client.whoami() + + assert excinfo.value.error_type == "auth_error" + mock_get.assert_not_called() + + @pytest.mark.parametrize("bad_key", ["", "[REDACTED]"]) + def test_cypher_unusable_credential_is_auth_error(self, bad_key): + from context_intelligence.client import CIClient, CIClientError + + client = CIClient("http://localhost:8000", bad_key) + + with patch("context_intelligence.client._http_post") as mock_post: + with pytest.raises(CIClientError) as excinfo: + client.cypher("MATCH (n) RETURN n") + + assert excinfo.value.error_type == "auth_error" + mock_post.assert_not_called() + + @pytest.mark.parametrize("bad_key", ["", "[REDACTED]"]) + def test_fetch_blob_unusable_credential_is_auth_error(self, bad_key): + from context_intelligence.client import CIClient, CIClientError + + client = CIClient("http://localhost:8000", bad_key) + + with patch("context_intelligence.client._http_get") as mock_get: + with pytest.raises(CIClientError) as excinfo: + client.fetch_blob("session1", "key1") + + assert excinfo.value.error_type == "auth_error" + mock_get.assert_not_called() + + # -- (b) genuinely malformed JSON from a 200 -> decode_error (unchanged) -- + + def test_session_summary_malformed_json_is_decode_error(self): + """A real 200 with a body that fails to parse as JSON must still + classify as decode_error -- proves the auth_error fix left this path alone.""" + from context_intelligence.client import CIClient, CIClientError + + behavior = _GenericBehavior() + behavior.body = b"not json{{{" + server, base_url = _start_generic_server(behavior) + client = CIClient(base_url, "key") + try: + with pytest.raises(CIClientError) as excinfo: + client.session_summary("session1") + finally: + server.shutdown() + server.server_close() + + assert excinfo.value.error_type == "decode_error" + + def test_delete_session_malformed_json_is_decode_error(self): + from context_intelligence.client import CIClient, CIClientError + + behavior = _GenericBehavior() + behavior.body = b"not json{{{" + server, base_url = _start_generic_server(behavior) + client = CIClient(base_url, "key") + try: + with pytest.raises(CIClientError) as excinfo: + client.delete_session("session1") + finally: + server.shutdown() + server.server_close() + + assert excinfo.value.error_type == "decode_error" + + def test_whoami_malformed_json_is_decode_error(self): + from context_intelligence.client import CIClient, CIClientError + + behavior = _GenericBehavior() + behavior.body = b"not json{{{" + server, base_url = _start_generic_server(behavior) + client = CIClient(base_url, "key") + try: + with pytest.raises(CIClientError) as excinfo: + client.whoami() + finally: + server.shutdown() + server.server_close() + + assert excinfo.value.error_type == "decode_error" + + # -- (c) real 401 -> http_status (unchanged) ----------------------------- + + def test_session_summary_401_is_http_status(self): + from context_intelligence.client import CIClient, CIClientError + + behavior = _GenericBehavior() + behavior.status_code = 401 + behavior.body = b'{"detail": "unauthorized"}' + server, base_url = _start_generic_server(behavior) + client = CIClient(base_url, "key") + try: + with pytest.raises(CIClientError) as excinfo: + client.session_summary("session1") + finally: + server.shutdown() + server.server_close() + + assert excinfo.value.error_type == "http_status" + assert excinfo.value.status_code == 401 + + def test_whoami_401_is_http_status(self): + from context_intelligence.client import CIClient, CIClientError + + behavior = _GenericBehavior() + behavior.status_code = 401 + behavior.body = b'{"detail": "unauthorized"}' + server, base_url = _start_generic_server(behavior) + client = CIClient(base_url, "key") + try: + with pytest.raises(CIClientError) as excinfo: + client.whoami() + finally: + server.shutdown() + server.server_close() + + assert excinfo.value.error_type == "http_status" + assert excinfo.value.status_code == 401 + + class TestLogger: """Logger must be named context_intelligence.client.""" @@ -497,158 +964,437 @@ def _make_async_httpx_client(mock_response): return mock_ctx -# --------------------------------------------------------------------------- -# AsyncCIClient tests -# --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# AsyncCIClient tests +# --------------------------------------------------------------------------- + + +class TestAsyncCIClientCypher: + """AsyncCIClient.cypher() must POST to /cypher and return list[dict].""" + + async def test_async_cypher_returns_results(self): + """cypher() returns a list of dicts from the server response.""" + from context_intelligence.client import AsyncCIClient + + mock_data = [{"n": {"id": "1"}}, {"n": {"id": "2"}}] + mock_resp = _make_async_mock_response(mock_data) + mock_http = _make_async_httpx_client(mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + result = await client.cypher("MATCH (n) RETURN n LIMIT 2") + + assert isinstance(result, list) + assert result == mock_data + + async def test_async_cypher_sends_correct_body(self): + """cypher() sends query and workspace in the POST body.""" + from context_intelligence.client import AsyncCIClient + + mock_resp = _make_async_mock_response([]) + mock_http = _make_async_httpx_client(mock_resp) + mock_inner_client = mock_http.__aenter__.return_value + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + await client.cypher("MATCH (n) RETURN n", workspace="myworkspace") + + call_kwargs = mock_inner_client.post.call_args + sent_json = call_kwargs[1].get("json") or call_kwargs[0][1] + assert sent_json["query"] == "MATCH (n) RETURN n" + assert sent_json["workspace"] == "myworkspace" + + async def test_async_cypher_default_workspace_is_star(self): + """cypher() defaults workspace to '*'.""" + from context_intelligence.client import AsyncCIClient + + mock_resp = _make_async_mock_response([]) + mock_http = _make_async_httpx_client(mock_resp) + mock_inner_client = mock_http.__aenter__.return_value + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + await client.cypher("MATCH (n) RETURN n") + + call_kwargs = mock_inner_client.post.call_args + sent_json = call_kwargs[1].get("json") or call_kwargs[0][1] + assert sent_json["workspace"] == "*" + + async def test_async_cypher_sends_auth_header(self): + """cypher() sends Authorization: Bearer header.""" + from context_intelligence.client import AsyncCIClient + + mock_resp = _make_async_mock_response([]) + mock_http = _make_async_httpx_client(mock_resp) + mock_inner_client = mock_http.__aenter__.return_value + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "secretkey") + await client.cypher("MATCH (n) RETURN n") + + call_kwargs = mock_inner_client.post.call_args + sent_headers = call_kwargs[1].get("headers") or call_kwargs[0][2] + assert sent_headers.get("Authorization") == "Bearer secretkey" + + async def test_async_cypher_returns_empty_list_on_none(self): + """cypher() returns [] when the server response is None/null.""" + from context_intelligence.client import AsyncCIClient + + mock_resp = _make_async_mock_response(None) + mock_http = _make_async_httpx_client(mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + result = await client.cypher("MATCH (n) RETURN n") + + assert result == [] + + async def test_async_cypher_unwraps_results_key(self): + """cypher() unwraps {'results': [...]} server response.""" + from context_intelligence.client import AsyncCIClient + + inner = [{"session_count": 5}] + mock_resp = _make_async_mock_response({"results": inner}) + mock_http = _make_async_httpx_client(mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + result = await client.cypher("MATCH (n) RETURN n") + + assert result == inner + + +class TestAsyncCIClientFetchBlob: + """AsyncCIClient.fetch_blob() must GET /blobs/{session_id}/{key}.""" + + async def test_async_fetch_blob_returns_parsed_json(self): + """fetch_blob() returns the parsed JSON response content.""" + from context_intelligence.client import AsyncCIClient + + blob_data = {"payload": "content here"} + mock_resp = _make_async_mock_response(blob_data) + mock_http = _make_async_httpx_client(mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + result = await client.fetch_blob("session1", "mykey") + + assert result == blob_data + + async def test_async_fetch_blob_calls_correct_url(self): + """fetch_blob() calls {server_url}/blobs/{session_id}/{key}.""" + from context_intelligence.client import AsyncCIClient + + mock_resp = _make_async_mock_response({}) + mock_http = _make_async_httpx_client(mock_resp) + mock_inner_client = mock_http.__aenter__.return_value + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + await client.fetch_blob("my-session", "my-key") + + call_args = mock_inner_client.get.call_args + url = call_args[0][0] if call_args[0] else call_args[1]["url"] + assert "http://localhost:8000" in url + assert "/blobs/" in url + assert "my-session" in url + assert "my-key" in url + + async def test_async_fetch_blob_raises_on_404(self): + """fetch_blob() raises CIClientError(error_type='http_status') on 404. + + Phase 0 (docs/multi-source-build-spec-v5.md §3): a non-2xx status is a + genuine failure and must never masquerade as an empty/None success. The + mock's raise_for_status() side effect is a plain Exception (not + httpx.HTTPStatusError), which the client's `except httpx.HTTPError` catch + (a real httpx.TransportError subclass check) does not match -- so this + also exercises real httpx.HTTPStatusError classification via a real + response object. + """ + import httpx + + from context_intelligence.client import AsyncCIClient, CIClientError + + request = httpx.Request("GET", "http://localhost:8000/blobs/session1/missing-key") + real_response = httpx.Response(status_code=404, request=request) + mock_resp = MagicMock() + mock_resp.status_code = 404 + mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "404", request=request, response=real_response + ) + mock_http = _make_async_httpx_client(mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + with pytest.raises(CIClientError) as exc_info: + await client.fetch_blob("session1", "missing-key") + + assert exc_info.value.error_type == "http_status" + assert exc_info.value.status_code == 404 + + async def test_async_fetch_blob_sends_auth_header(self): + """fetch_blob() sends Authorization: Bearer header.""" + from context_intelligence.client import AsyncCIClient + + mock_resp = _make_async_mock_response({}) + mock_http = _make_async_httpx_client(mock_resp) + mock_inner_client = mock_http.__aenter__.return_value + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "blobkey") + await client.fetch_blob("sess", "k") + + call_kwargs = mock_inner_client.get.call_args + sent_headers = call_kwargs[1].get("headers") or call_kwargs[0][1] + assert sent_headers.get("Authorization") == "Bearer blobkey" + + +class TestAsyncCIClientSessionSummary: + """AsyncCIClient.session_summary() must GET /sessions/{id}/summary.""" + + async def test_async_session_summary_returns_parsed_dict(self): + """session_summary() returns the parsed summary dict from the server.""" + from context_intelligence.client import AsyncCIClient + + summary_data = {"created_by": "alice", "node_count": 10, "deletable": True} + mock_resp = _make_async_mock_response(summary_data) + mock_http = _make_async_httpx_client(mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + result = await client.session_summary("session1") + + assert result == summary_data + + async def test_async_session_summary_calls_correct_url_and_method(self): + """session_summary() GETs {server_url}/sessions/{session_id}/summary.""" + from context_intelligence.client import AsyncCIClient + + mock_resp = _make_async_mock_response({}) + mock_http = _make_async_httpx_client(mock_resp) + mock_inner_client = mock_http.__aenter__.return_value + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + await client.session_summary("my-session") + + assert mock_inner_client.get.called, "session_summary must use GET" + call_args = mock_inner_client.get.call_args + url = call_args[0][0] if call_args[0] else call_args[1]["url"] + assert url == "http://localhost:8000/sessions/my-session/summary" + + async def test_async_session_summary_sends_auth_header(self): + """session_summary() sends Authorization: [REDACTED:SECRET]""" + from context_intelligence.client import AsyncCIClient + + mock_resp = _make_async_mock_response({}) + mock_http = _make_async_httpx_client(mock_resp) + mock_inner_client = mock_http.__aenter__.return_value + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "secretkey") + await client.session_summary("my-session") + + call_kwargs = mock_inner_client.get.call_args + sent_headers = call_kwargs[1].get("headers") or call_kwargs[0][1] + assert sent_headers.get("Authorization") == "Bearer secretkey" + + async def test_async_session_summary_raises_on_404(self): + """A 404 (unknown session) raises CIClientError(error_type='http_status').""" + import httpx + + from context_intelligence.client import AsyncCIClient, CIClientError + + request = httpx.Request("GET", "http://localhost:8000/sessions/missing/summary") + real_response = httpx.Response(status_code=404, request=request) + mock_resp = MagicMock() + mock_resp.status_code = 404 + mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "404", request=request, response=real_response + ) + mock_http = _make_async_httpx_client(mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + with pytest.raises(CIClientError) as exc_info: + await client.session_summary("missing") + assert exc_info.value.error_type == "http_status" + assert exc_info.value.status_code == 404 -class TestAsyncCIClientCypher: - """AsyncCIClient.cypher() must POST to /cypher and return list[dict].""" + async def test_async_session_summary_raises_on_409(self): + """A 409 (still receiving data / ambiguous id) raises CIClientError + with the status preserved, not a silently-empty result.""" + import httpx - async def test_async_cypher_returns_results(self): - """cypher() returns a list of dicts from the server response.""" - from context_intelligence.client import AsyncCIClient + from context_intelligence.client import AsyncCIClient, CIClientError - mock_data = [{"n": {"id": "1"}}, {"n": {"id": "2"}}] - mock_resp = _make_async_mock_response(mock_data) + request = httpx.Request("GET", "http://localhost:8000/sessions/live/summary") + real_response = httpx.Response(status_code=409, request=request) + mock_resp = MagicMock() + mock_resp.status_code = 409 + mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "409", request=request, response=real_response + ) mock_http = _make_async_httpx_client(mock_resp) with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): client = AsyncCIClient("http://localhost:8000", "testkey") - result = await client.cypher("MATCH (n) RETURN n LIMIT 2") + with pytest.raises(CIClientError) as exc_info: + await client.session_summary("live") - assert isinstance(result, list) - assert result == mock_data + assert exc_info.value.error_type == "http_status" + assert exc_info.value.status_code == 409 - async def test_async_cypher_sends_correct_body(self): - """cypher() sends query and workspace in the POST body.""" + +class TestAsyncCIClientWhoami: + """AsyncCIClient.whoami() must GET /whoami.""" + + async def test_async_whoami_returns_parsed_dict(self): + """whoami() returns the parsed identity dict from the server.""" from context_intelligence.client import AsyncCIClient - mock_resp = _make_async_mock_response([]) + identity_data = {"contributor_id": "octocat"} + mock_resp = _make_async_mock_response(identity_data) mock_http = _make_async_httpx_client(mock_resp) - mock_inner_client = mock_http.__aenter__.return_value with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): client = AsyncCIClient("http://localhost:8000", "testkey") - await client.cypher("MATCH (n) RETURN n", workspace="myworkspace") + result = await client.whoami() - call_kwargs = mock_inner_client.post.call_args - sent_json = call_kwargs[1].get("json") or call_kwargs[0][1] - assert sent_json["query"] == "MATCH (n) RETURN n" - assert sent_json["workspace"] == "myworkspace" + assert result == identity_data - async def test_async_cypher_default_workspace_is_star(self): - """cypher() defaults workspace to '*'.""" + async def test_async_whoami_calls_correct_url_and_method(self): + """whoami() GETs {server_url}/whoami.""" from context_intelligence.client import AsyncCIClient - mock_resp = _make_async_mock_response([]) + mock_resp = _make_async_mock_response({}) mock_http = _make_async_httpx_client(mock_resp) mock_inner_client = mock_http.__aenter__.return_value with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): client = AsyncCIClient("http://localhost:8000", "testkey") - await client.cypher("MATCH (n) RETURN n") + await client.whoami() - call_kwargs = mock_inner_client.post.call_args - sent_json = call_kwargs[1].get("json") or call_kwargs[0][1] - assert sent_json["workspace"] == "*" + assert mock_inner_client.get.called, "whoami must use GET" + call_args = mock_inner_client.get.call_args + url = call_args[0][0] if call_args[0] else call_args[1]["url"] + assert url == "http://localhost:8000/whoami" - async def test_async_cypher_sends_auth_header(self): - """cypher() sends Authorization: Bearer header.""" + async def test_async_whoami_sends_auth_header(self): + """whoami() sends Authorization: Bearer .""" from context_intelligence.client import AsyncCIClient - mock_resp = _make_async_mock_response([]) + mock_resp = _make_async_mock_response({}) mock_http = _make_async_httpx_client(mock_resp) mock_inner_client = mock_http.__aenter__.return_value with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): client = AsyncCIClient("http://localhost:8000", "secretkey") - await client.cypher("MATCH (n) RETURN n") + await client.whoami() - call_kwargs = mock_inner_client.post.call_args - sent_headers = call_kwargs[1].get("headers") or call_kwargs[0][2] + call_kwargs = mock_inner_client.get.call_args + sent_headers = call_kwargs[1].get("headers") or call_kwargs[0][1] assert sent_headers.get("Authorization") == "Bearer secretkey" - async def test_async_cypher_returns_empty_list_on_none(self): - """cypher() returns [] when the server response is None/null.""" + async def test_async_whoami_returns_null_contributor_id_when_auth_disabled(self): + """A server with auth disabled returns contributor_id: null -- passed through.""" from context_intelligence.client import AsyncCIClient - mock_resp = _make_async_mock_response(None) + mock_resp = _make_async_mock_response({"contributor_id": None}) mock_http = _make_async_httpx_client(mock_resp) with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): client = AsyncCIClient("http://localhost:8000", "testkey") - result = await client.cypher("MATCH (n) RETURN n") + result = await client.whoami() - assert result == [] + assert result == {"contributor_id": None} - async def test_async_cypher_unwraps_results_key(self): - """cypher() unwraps {'results': [...]} server response.""" - from context_intelligence.client import AsyncCIClient + async def test_async_whoami_raises_on_500(self): + """A genuine HTTP failure raises CIClientError(error_type='http_status').""" + import httpx - inner = [{"session_count": 5}] - mock_resp = _make_async_mock_response({"results": inner}) + from context_intelligence.client import AsyncCIClient, CIClientError + + request = httpx.Request("GET", "http://localhost:8000/whoami") + real_response = httpx.Response(status_code=500, request=request) + mock_resp = MagicMock() + mock_resp.status_code = 500 + mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "500", request=request, response=real_response + ) mock_http = _make_async_httpx_client(mock_resp) with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): client = AsyncCIClient("http://localhost:8000", "testkey") - result = await client.cypher("MATCH (n) RETURN n") + with pytest.raises(CIClientError) as exc_info: + await client.whoami() - assert result == inner + assert exc_info.value.error_type == "http_status" + assert exc_info.value.status_code == 500 -class TestAsyncCIClientFetchBlob: - """AsyncCIClient.fetch_blob() must GET /blobs/{session_id}/{key}.""" +class TestAsyncCIClientDeleteSession: + """AsyncCIClient.delete_session() must DELETE /sessions/{id}.""" - async def test_async_fetch_blob_returns_parsed_json(self): - """fetch_blob() returns the parsed JSON response content.""" + async def test_async_delete_session_returns_parsed_dict(self): + """delete_session() returns the parsed result-counts dict from the server.""" from context_intelligence.client import AsyncCIClient - blob_data = {"payload": "content here"} - mock_resp = _make_async_mock_response(blob_data) + result_data = {"root_id": "session1", "nodes_deleted": 42, "blobs_deleted": 2} + mock_resp = _make_async_mock_response(result_data) mock_http = _make_async_httpx_client(mock_resp) + mock_inner_client = mock_http.__aenter__.return_value + mock_inner_client.delete = AsyncMock(return_value=mock_resp) with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): client = AsyncCIClient("http://localhost:8000", "testkey") - result = await client.fetch_blob("session1", "mykey") + result = await client.delete_session("session1") - assert result == blob_data + assert result == result_data - async def test_async_fetch_blob_calls_correct_url(self): - """fetch_blob() calls {server_url}/blobs/{session_id}/{key}.""" + async def test_async_delete_session_calls_correct_url_and_method(self): + """delete_session() DELETEs {server_url}/sessions/{session_id} (no query/body).""" from context_intelligence.client import AsyncCIClient mock_resp = _make_async_mock_response({}) mock_http = _make_async_httpx_client(mock_resp) mock_inner_client = mock_http.__aenter__.return_value + mock_inner_client.delete = AsyncMock(return_value=mock_resp) with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): client = AsyncCIClient("http://localhost:8000", "testkey") - await client.fetch_blob("my-session", "my-key") + await client.delete_session("my-session") - call_args = mock_inner_client.get.call_args + assert mock_inner_client.delete.called, "delete_session must use DELETE" + call_args = mock_inner_client.delete.call_args url = call_args[0][0] if call_args[0] else call_args[1]["url"] - assert "http://localhost:8000" in url - assert "/blobs/" in url - assert "my-session" in url - assert "my-key" in url + assert url == "http://localhost:8000/sessions/my-session" - async def test_async_fetch_blob_raises_on_404(self): - """fetch_blob() raises CIClientError(error_type='http_status') on 404. + async def test_async_delete_session_sends_auth_header(self): + """delete_session() sends Authorization: [REDACTED:SECRET]""" + from context_intelligence.client import AsyncCIClient - Phase 0 (docs/multi-source-build-spec-v5.md §3): a non-2xx status is a - genuine failure and must never masquerade as an empty/None success. The - mock's raise_for_status() side effect is a plain Exception (not - httpx.HTTPStatusError), which the client's `except httpx.HTTPError` catch - (a real httpx.TransportError subclass check) does not match -- so this - also exercises real httpx.HTTPStatusError classification via a real - response object. - """ + mock_resp = _make_async_mock_response({}) + mock_http = _make_async_httpx_client(mock_resp) + mock_inner_client = mock_http.__aenter__.return_value + mock_inner_client.delete = AsyncMock(return_value=mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "secretkey") + await client.delete_session("my-session") + + call_kwargs = mock_inner_client.delete.call_args + sent_headers = call_kwargs[1].get("headers") or call_kwargs[0][1] + assert sent_headers.get("Authorization") == "Bearer secretkey" + + async def test_async_delete_session_raises_on_404(self): + """A 404 (unknown session) raises CIClientError(error_type='http_status').""" import httpx from context_intelligence.client import AsyncCIClient, CIClientError - request = httpx.Request("GET", "http://localhost:8000/blobs/session1/missing-key") + request = httpx.Request("DELETE", "http://localhost:8000/sessions/missing") real_response = httpx.Response(status_code=404, request=request) mock_resp = MagicMock() mock_resp.status_code = 404 @@ -656,30 +1402,42 @@ async def test_async_fetch_blob_raises_on_404(self): "404", request=request, response=real_response ) mock_http = _make_async_httpx_client(mock_resp) + mock_inner_client = mock_http.__aenter__.return_value + mock_inner_client.delete = AsyncMock(return_value=mock_resp) with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): client = AsyncCIClient("http://localhost:8000", "testkey") with pytest.raises(CIClientError) as exc_info: - await client.fetch_blob("session1", "missing-key") + await client.delete_session("missing") assert exc_info.value.error_type == "http_status" assert exc_info.value.status_code == 404 - async def test_async_fetch_blob_sends_auth_header(self): - """fetch_blob() sends Authorization: Bearer header.""" - from context_intelligence.client import AsyncCIClient + async def test_async_delete_session_raises_on_409(self): + """A 409 (still receiving data / ambiguous id) raises CIClientError -- + the delete is never silently treated as done.""" + import httpx - mock_resp = _make_async_mock_response({}) + from context_intelligence.client import AsyncCIClient, CIClientError + + request = httpx.Request("DELETE", "http://localhost:8000/sessions/live") + real_response = httpx.Response(status_code=409, request=request) + mock_resp = MagicMock() + mock_resp.status_code = 409 + mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "409", request=request, response=real_response + ) mock_http = _make_async_httpx_client(mock_resp) mock_inner_client = mock_http.__aenter__.return_value + mock_inner_client.delete = AsyncMock(return_value=mock_resp) with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): - client = AsyncCIClient("http://localhost:8000", "blobkey") - await client.fetch_blob("sess", "k") + client = AsyncCIClient("http://localhost:8000", "testkey") + with pytest.raises(CIClientError) as exc_info: + await client.delete_session("live") - call_kwargs = mock_inner_client.get.call_args - sent_headers = call_kwargs[1].get("headers") or call_kwargs[0][1] - assert sent_headers.get("Authorization") == "Bearer blobkey" + assert exc_info.value.error_type == "http_status" + assert exc_info.value.status_code == 409 class TestAsyncCIClientListBlobKeys: @@ -793,6 +1551,178 @@ async def test_async_health_check_returns_zero_on_empty(self): assert result["session_count"] == 0 +class TestAsyncCIClientErrorClassification: + """error_type classification must be correct: auth_error vs decode_error vs http_status. + + Regression guard: ``self._strategy.headers()`` was previously called INSIDE + each method's request ``try:`` block, so a credential ``ValueError`` (unusable/ + redacted api_key) fell through to the SAME ``except (ValueError, + json.JSONDecodeError)`` handler used for a genuinely malformed JSON response + and was misreported as "malformed JSON from {url}" -- even though no request + was ever sent. ``_auth_headers()`` now computes headers BEFORE the try block + and classifies a credential failure as its own ``auth_error``. + """ + + # -- (a) unusable credential -> auth_error, NEVER decode_error ---------- + + @pytest.mark.parametrize("bad_key", ["", "[REDACTED]"]) + async def test_async_session_summary_unusable_credential_is_auth_error(self, bad_key): + from context_intelligence.client import AsyncCIClient, CIClientError + + client = AsyncCIClient("http://localhost:8000", bad_key) + + with patch("context_intelligence.client.httpx.AsyncClient") as mock_async_client: + with pytest.raises(CIClientError) as excinfo: + await client.session_summary("session1") + + assert excinfo.value.error_type == "auth_error" + mock_async_client.assert_not_called() + + @pytest.mark.parametrize("bad_key", ["", "[REDACTED]"]) + async def test_async_delete_session_unusable_credential_is_auth_error(self, bad_key): + from context_intelligence.client import AsyncCIClient, CIClientError + + client = AsyncCIClient("http://localhost:8000", bad_key) + + with patch("context_intelligence.client.httpx.AsyncClient") as mock_async_client: + with pytest.raises(CIClientError) as excinfo: + await client.delete_session("session1") + + assert excinfo.value.error_type == "auth_error" + mock_async_client.assert_not_called() + + @pytest.mark.parametrize("bad_key", ["", "[REDACTED]"]) + async def test_async_whoami_unusable_credential_is_auth_error(self, bad_key): + from context_intelligence.client import AsyncCIClient, CIClientError + + client = AsyncCIClient("http://localhost:8000", bad_key) + + with patch("context_intelligence.client.httpx.AsyncClient") as mock_async_client: + with pytest.raises(CIClientError) as excinfo: + await client.whoami() + + assert excinfo.value.error_type == "auth_error" + mock_async_client.assert_not_called() + + @pytest.mark.parametrize("bad_key", ["", "[REDACTED]"]) + async def test_async_cypher_unusable_credential_is_auth_error(self, bad_key): + from context_intelligence.client import AsyncCIClient, CIClientError + + client = AsyncCIClient("http://localhost:8000", bad_key) + + with patch("context_intelligence.client.httpx.AsyncClient") as mock_async_client: + with pytest.raises(CIClientError) as excinfo: + await client.cypher("MATCH (n) RETURN n") + + assert excinfo.value.error_type == "auth_error" + mock_async_client.assert_not_called() + + @pytest.mark.parametrize("bad_key", ["", "[REDACTED]"]) + async def test_async_fetch_blob_unusable_credential_is_auth_error(self, bad_key): + from context_intelligence.client import AsyncCIClient, CIClientError + + client = AsyncCIClient("http://localhost:8000", bad_key) + + with patch("context_intelligence.client.httpx.AsyncClient") as mock_async_client: + with pytest.raises(CIClientError) as excinfo: + await client.fetch_blob("session1", "key1") + + assert excinfo.value.error_type == "auth_error" + mock_async_client.assert_not_called() + + # -- (b) genuinely malformed JSON from a 200 -> decode_error (unchanged) -- + + async def test_async_session_summary_malformed_json_is_decode_error(self): + from context_intelligence.client import AsyncCIClient, CIClientError + + mock_resp = _make_async_mock_response(None) + mock_resp.json.side_effect = ValueError("Expecting value") + mock_http = _make_async_httpx_client(mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + with pytest.raises(CIClientError) as excinfo: + await client.session_summary("session1") + + assert excinfo.value.error_type == "decode_error" + + async def test_async_delete_session_malformed_json_is_decode_error(self): + from context_intelligence.client import AsyncCIClient, CIClientError + + mock_resp = _make_async_mock_response(None) + mock_resp.json.side_effect = ValueError("Expecting value") + mock_http = _make_async_httpx_client(mock_resp) + mock_inner_client = mock_http.__aenter__.return_value + mock_inner_client.delete = AsyncMock(return_value=mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + with pytest.raises(CIClientError) as excinfo: + await client.delete_session("session1") + + assert excinfo.value.error_type == "decode_error" + + async def test_async_whoami_malformed_json_is_decode_error(self): + from context_intelligence.client import AsyncCIClient, CIClientError + + mock_resp = _make_async_mock_response(None) + mock_resp.json.side_effect = ValueError("Expecting value") + mock_http = _make_async_httpx_client(mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + with pytest.raises(CIClientError) as excinfo: + await client.whoami() + + assert excinfo.value.error_type == "decode_error" + + # -- (c) real 401 -> http_status (unchanged) ----------------------------- + + async def test_async_session_summary_401_is_http_status(self): + import httpx + + from context_intelligence.client import AsyncCIClient, CIClientError + + request = httpx.Request("GET", "http://localhost:8000/sessions/session1/summary") + real_response = httpx.Response(status_code=401, request=request) + mock_resp = MagicMock() + mock_resp.status_code = 401 + mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "401", request=request, response=real_response + ) + mock_http = _make_async_httpx_client(mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + with pytest.raises(CIClientError) as excinfo: + await client.session_summary("session1") + + assert excinfo.value.error_type == "http_status" + assert excinfo.value.status_code == 401 + + async def test_async_whoami_401_is_http_status(self): + import httpx + + from context_intelligence.client import AsyncCIClient, CIClientError + + request = httpx.Request("GET", "http://localhost:8000/whoami") + real_response = httpx.Response(status_code=401, request=request) + mock_resp = MagicMock() + mock_resp.status_code = 401 + mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "401", request=request, response=real_response + ) + mock_http = _make_async_httpx_client(mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + with pytest.raises(CIClientError) as excinfo: + await client.whoami() + + assert excinfo.value.error_type == "http_status" + assert excinfo.value.status_code == 401 + + # --------------------------------------------------------------------------- # REAL-SOCKET fail-loud tests for list_blob_keys (sync + async) # @@ -1147,3 +2077,56 @@ async def test_dict_envelope_empty_blobs_returns_empty_set(self): server.shutdown() server.server_close() assert result == set() + + +class TestRetryAfterParsing: + """_retry_after_seconds parses the Retry-After delta-seconds header.""" + + def test_parses_positive_integer(self): + from context_intelligence.client import _retry_after_seconds + + assert _retry_after_seconds({"Retry-After": "2"}) == 2 + + def test_zero_is_valid(self): + from context_intelligence.client import _retry_after_seconds + + assert _retry_after_seconds({"Retry-After": "0"}) == 0 + + def test_absent_header_is_none(self): + from context_intelligence.client import _retry_after_seconds + + assert _retry_after_seconds({}) is None + + def test_none_headers_is_none(self): + from context_intelligence.client import _retry_after_seconds + + assert _retry_after_seconds(None) is None + + def test_non_integer_is_none(self): + from context_intelligence.client import _retry_after_seconds + + # HTTP-date form is not used by this server -> treated as absent. + assert _retry_after_seconds({"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"}) is None + + def test_negative_is_none(self): + from context_intelligence.client import _retry_after_seconds + + assert _retry_after_seconds({"Retry-After": "-5"}) is None + + def test_ciclienterror_carries_retry_after(self): + from context_intelligence.client import CIClientError + + exc = CIClientError( + "HTTP 409", + error_type="http_status", + url="http://x/sessions/s", + status_code=409, + retry_after=2, + ) + assert exc.retry_after == 2 + + def test_ciclienterror_retry_after_defaults_none(self): + from context_intelligence.client import CIClientError + + exc = CIClientError("boom", error_type="timeout", url="http://x") + assert exc.retry_after is None diff --git a/tests/test_whoami_tool.py b/tests/test_whoami_tool.py new file mode 100644 index 00000000..addad804 --- /dev/null +++ b/tests/test_whoami_tool.py @@ -0,0 +1,429 @@ +"""Tests for WhoamiTool. + +Constructor: WhoamiTool(coordinator, resolver=None). Patch path is +context_intelligence.whoami_tool. +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _make_coordinator(resolver: Any = None) -> MagicMock: + coordinator = MagicMock() + coordinator.config = {} + coordinator.get_capability = MagicMock(return_value=resolver) + return coordinator + + +def _make_hook_resolver( + server_url: str | None = "http://localhost:8080", + workspace: str = "test-workspace", + api_key: str = "test-api-key", +) -> MagicMock: + """Create a hook resolver mock (returned by get_capability).""" + resolver = MagicMock() + resolver.workspace = workspace + if server_url: + resolver.destinations = { + "default": SimpleNamespace(name="default", url=server_url, api_key=api_key) + } + else: + resolver.destinations = {} + return resolver + + +def _make_hook_resolver_with_dests(destinations: dict) -> MagicMock: + """Hook resolver mock with a specific destinations dict.""" + resolver = MagicMock() + resolver.workspace = "test-workspace" + resolver.destinations = destinations + return resolver + + +def _make_mock_async_ci_client(return_value: Any = None): + """Return (mock_instance, mock_cls) for patching AsyncCIClient.""" + mock_instance = AsyncMock() + mock_instance.whoami = AsyncMock( + return_value=return_value if return_value is not None else {"contributor_id": "alice"} + ) + mock_cls = MagicMock(return_value=mock_instance) + return mock_instance, mock_cls + + +def _make_tool_resolver(config: dict, coordinator: Any = None) -> Any: + """Build a real ToolConfigResolver from a config dict (for injection).""" + from context_intelligence.tool_resolver import ToolConfigResolver + + coord = coordinator or MagicMock() + coord.config = {} + return ToolConfigResolver(config, coord) + + +# --------------------------------------------------------------------------- +# TestWhoamiToolProtocol +# --------------------------------------------------------------------------- + + +class TestWhoamiToolProtocol: + """Tool protocol surface tests.""" + + def test_name_is_whoami(self) -> None: + from context_intelligence.whoami_tool import WhoamiTool + + tool = WhoamiTool(_make_coordinator()) + assert tool.name == "whoami" + + def test_description_mentions_contributor_id(self) -> None: + from context_intelligence.whoami_tool import WhoamiTool + + tool = WhoamiTool(_make_coordinator()) + assert "contributor_id" in tool.description + + def test_input_schema_returns_object_type(self) -> None: + from context_intelligence.whoami_tool import WhoamiTool + + tool = WhoamiTool(_make_coordinator()) + assert tool.input_schema["type"] == "object" + + def test_input_schema_has_optional_source_and_list_sources(self) -> None: + from context_intelligence.whoami_tool import WhoamiTool + + tool = WhoamiTool(_make_coordinator()) + props = tool.input_schema["properties"] + assert "source" in props + assert "list_sources" in props + assert "source" not in tool.input_schema["required"] + assert "list_sources" not in tool.input_schema["required"] + # whoami takes no session_id -- there is no session to look up. + assert "session_id" not in props + + async def test_execute_returns_tool_result(self) -> None: + from amplifier_core.models import ToolResult + + from context_intelligence.whoami_tool import WhoamiTool + + hook_resolver = _make_hook_resolver() + coordinator = _make_coordinator(resolver=hook_resolver) + tool = WhoamiTool(coordinator) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "context_intelligence.whoami_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({}) + + assert isinstance(result, ToolResult) + + +# --------------------------------------------------------------------------- +# TestListSources +# --------------------------------------------------------------------------- + + +class TestListSources: + async def test_list_sources_does_not_call_client(self) -> None: + from context_intelligence.whoami_tool import WhoamiTool + + resolver = _make_tool_resolver( + {"sources": {"only": {"url": "http://only.example.com", "api_key": "k"}}} + ) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = WhoamiTool(coordinator, resolver) + + mock_cls = MagicMock() + with patch( + "context_intelligence.whoami_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"list_sources": True}) + + assert result.success is True + assert result.output is not None + names = {e["name"] for e in result.output["connectable_set"]} + assert names == {"only"} + mock_cls.assert_not_called() + + +# --------------------------------------------------------------------------- +# TestWhoamiConstruction -- AsyncCIClient construction and delegation +# --------------------------------------------------------------------------- + + +class TestWhoamiConstruction: + """AsyncCIClient construction and delegation tests (mirrors SessionSummaryTool).""" + + async def test_client_constructed_with_server_url_and_api_key(self) -> None: + from context_intelligence.whoami_tool import WhoamiTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000", api_key="my-key") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = WhoamiTool(coordinator) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "context_intelligence.whoami_tool.AsyncCIClient", + mock_cls, + ): + await tool.execute({}) + + mock_cls.assert_called_once() + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs.get("server_url") == "http://ci-server:9000" + assert call_kwargs.get("api_key") == "my-key" + + async def test_whoami_called_with_no_arguments(self) -> None: + from context_intelligence.whoami_tool import WhoamiTool + + hook_resolver = _make_hook_resolver() + coordinator = _make_coordinator(resolver=hook_resolver) + tool = WhoamiTool(coordinator) + + mock_instance, mock_cls = _make_mock_async_ci_client() + with patch( + "context_intelligence.whoami_tool.AsyncCIClient", + mock_cls, + ): + await tool.execute({}) + + mock_instance.whoami.assert_called_once_with() + + async def test_result_forwarded_and_source_stamped(self) -> None: + from context_intelligence.whoami_tool import WhoamiTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = WhoamiTool(coordinator) + + _, mock_cls = _make_mock_async_ci_client(return_value={"contributor_id": "alice"}) + with patch( + "context_intelligence.whoami_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({}) + + assert result.success is True + assert result.output is not None + assert result.output["contributor_id"] == "alice" + assert result.output["source"] is not None + assert result.output["source"]["url"] == "http://ci-server:9000" + + async def test_null_contributor_id_when_auth_disabled(self) -> None: + """Server returns contributor_id: null when auth is disabled -- passed through as-is.""" + from context_intelligence.whoami_tool import WhoamiTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = WhoamiTool(coordinator) + + _, mock_cls = _make_mock_async_ci_client(return_value={"contributor_id": None}) + with patch( + "context_intelligence.whoami_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({}) + + assert result.success is True + assert result.output is not None + assert result.output["contributor_id"] is None + + +# --------------------------------------------------------------------------- +# TestWhoamiConfigFallback +# --------------------------------------------------------------------------- + + +class TestWhoamiConfigFallback: + async def test_capability_not_found_returns_configuration_error(self) -> None: + from context_intelligence.whoami_tool import WhoamiTool + + coordinator = _make_coordinator(resolver=None) + tool = WhoamiTool(coordinator) + clean = {k: "" for k in os.environ if k.startswith("AMPLIFIER_CONTEXT_INTELLIGENCE_")} + with patch.dict(os.environ, clean): + result = await tool.execute({}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "configuration_error" + + +# --------------------------------------------------------------------------- +# TestWhoamiSourceSelection -- pool/selection + fail-loud ambiguity +# --------------------------------------------------------------------------- + + +class TestWhoamiSourceSelection: + """execute() with an explicit `source` -- matching / not matching / omitted-with-2+.""" + + def _two_source_config(self) -> dict: + return { + "sources": { + "alpha": {"url": "http://alpha.example.com", "api_key": "alpha-key"}, + "beta": {"url": "http://beta.example.com", "api_key": "beta-key"}, + } + } + + async def test_source_matching_name_selects_that_source(self) -> None: + from context_intelligence.whoami_tool import WhoamiTool + + resolver = _make_tool_resolver(self._two_source_config()) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = WhoamiTool(coordinator, resolver) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "context_intelligence.whoami_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"source": "beta"}) + + assert result.success is True + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs["server_url"] == "http://beta.example.com" + assert call_kwargs["api_key"] == "beta-key" + + async def test_source_not_matching_returns_unknown_source_error(self) -> None: + from context_intelligence.whoami_tool import WhoamiTool + + resolver = _make_tool_resolver(self._two_source_config()) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = WhoamiTool(coordinator, resolver) + + result = await tool.execute({"source": "gamma"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "unknown_source" + assert result.error["valid_sources"] == ["alpha", "beta"] + + async def test_source_omitted_with_two_configured_returns_ambiguous_error(self) -> None: + from context_intelligence.whoami_tool import WhoamiTool + + resolver = _make_tool_resolver(self._two_source_config()) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = WhoamiTool(coordinator, resolver) + + result = await tool.execute({}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "ambiguous_source_selection" + assert result.error["valid_sources"] == ["alpha", "beta"] + + async def test_source_omitted_with_one_configured_still_succeeds(self) -> None: + """Safe to omit source with exactly one configured (backward compatible).""" + from context_intelligence.whoami_tool import WhoamiTool + + config = { + "sources": { + "default": {"url": "http://only.example.com", "api_key": "only-key"}, + } + } + resolver = _make_tool_resolver(config) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = WhoamiTool(coordinator, resolver) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "context_intelligence.whoami_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({}) + + assert result.success is True + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs["server_url"] == "http://only.example.com" + + async def test_selected_source_misconfigured_returns_source_misconfigured_error(self) -> None: + from context_intelligence.whoami_tool import WhoamiTool + + config = { + "sources": { + "good": {"url": "http://good.example.com", "api_key": "gk"}, + "bad": {"url": "", "api_key": ""}, + } + } + resolver = _make_tool_resolver(config) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = WhoamiTool(coordinator, resolver) + + result = await tool.execute({"source": "bad"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "source_misconfigured" + assert "bad" in result.error["message"] + + +# --------------------------------------------------------------------------- +# TestWhoamiServerErrors -- transport/HTTP failures surfaced as clear tool errors +# --------------------------------------------------------------------------- + + +class TestWhoamiServerErrors: + async def test_http_error_surfaces_as_clear_tool_error(self) -> None: + from context_intelligence.client import CIClientError + from context_intelligence.whoami_tool import WhoamiTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = WhoamiTool(coordinator) + + mock_instance = AsyncMock() + mock_instance.whoami = AsyncMock( + side_effect=CIClientError( + "HTTP 500 from http://ci-server:9000/whoami", + error_type="http_status", + url="http://ci-server:9000/whoami", + status_code=500, + ) + ) + mock_cls = MagicMock(return_value=mock_instance) + with patch( + "context_intelligence.whoami_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "http_status" + assert result.error["status_code"] == 500 + assert result.error["source"] is not None + + async def test_connection_error_surfaces_as_clear_tool_error(self) -> None: + from context_intelligence.client import CIClientError + from context_intelligence.whoami_tool import WhoamiTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = WhoamiTool(coordinator) + + mock_instance = AsyncMock() + mock_instance.whoami = AsyncMock( + side_effect=CIClientError( + "connection error to http://ci-server:9000/whoami: refused", + error_type="connection_error", + url="http://ci-server:9000/whoami", + ) + ) + mock_cls = MagicMock(return_value=mock_instance) + with patch( + "context_intelligence.whoami_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "connection_error" + assert result.error["source"] is not None diff --git a/uv.lock b/uv.lock index 25c25cb5..67352082 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "amplifier-core" }, { name = "httpx" }, { name = "idna" }, { name = "pyright" }, @@ -26,6 +27,7 @@ requires-dist = [{ name = "azure-identity", specifier = ">=1.19" }] [package.metadata.requires-dev] dev = [ + { name = "amplifier-core", specifier = ">=1.6.0" }, { name = "httpx", specifier = ">=0.25" }, { name = "idna", specifier = ">=3.15" }, { name = "pyright", specifier = ">=1.1.411" }, @@ -35,6 +37,35 @@ dev = [ { name = "ruff", specifier = ">=0.14" }, ] +[[package]] +name = "amplifier-core" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tomli" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/cd/8b0b520bf0de741ea73e069aaf64aca28c9f4ce91a7b8b9239193a6c4c1b/amplifier_core-1.6.1-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c0f711d8408de78e53e5deddcb38b7240c5c1c497ca51eeaaeff23559b3d3c48", size = 8281633, upload-time = "2026-08-10T02:38:11.98Z" }, + { url = "https://files.pythonhosted.org/packages/14/83/f4fb297d87d35b9d74058da02bb153e12f7891ab62b3aaf7e0857f877798/amplifier_core-1.6.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b08f37e2c0b1611349a0e25d5bf9bfdfae3afcee35488f8e26bba1cdd400503b", size = 7366930, upload-time = "2026-08-10T02:38:14.105Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/5eb9cecf92d8053c5e6d46ad9668c3ed3558d5423845c1dced1f266b2a38/amplifier_core-1.6.1-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ebf7e3993c76ea506e70ac7844b286c3ba2e9127b3bcb350fa4fcd2dcdbd38d", size = 7659512, upload-time = "2026-08-10T02:38:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/121f054e3d079dc33d83f3d8ba9af50fd9f7694c3e2ba3d7d23d7c157d48/amplifier_core-1.6.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c957cd0671d2a003f2c8f7d6a41bd6e808f97d183c57b97e7700bf4c912621d", size = 8678425, upload-time = "2026-08-10T02:38:18.243Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/bfc217f4a9ed2d033995fc59847f1fee2e1b17130632fcb0e0981a1a311b/amplifier_core-1.6.1-cp311-abi3-win_amd64.whl", hash = "sha256:50c80bcfa1f6efe769b19e7af18c925024c7553d4db08880727241709dd44eae", size = 8976601, upload-time = "2026-08-10T02:38:20.505Z" }, + { url = "https://files.pythonhosted.org/packages/a5/14/5f330452c92c6c5d35c51ad5311301949ce5db4d1a1a901456f3ee43eaac/amplifier_core-1.6.1-cp311-abi3-win_arm64.whl", hash = "sha256:cd8b617f132cf5d1ca3e5187d5f831d1f2a508bb40d07b2ab1085961bcb9e1a9", size = 7744837, upload-time = "2026-08-10T02:38:22.562Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + [[package]] name = "anyio" version = "4.13.0" @@ -258,6 +289,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, ] +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -440,6 +480,123 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -600,6 +757,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -609,6 +820,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + [[package]] name = "urllib3" version = "2.7.0"