diff --git a/.almanac.yaml b/.almanac.yaml new file mode 100644 index 00000000..bc8d8e23 --- /dev/null +++ b/.almanac.yaml @@ -0,0 +1 @@ +wiki: kushagra-chitkara/kushagra diff --git a/.gitignore b/.gitignore index 1ecf0e76..2768b8ed 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ almanac/jobs/ AGENTS.md remotion-video/ +videos/ .agents/skills/remotion-best-practices/ .npmrc diff --git a/CLAUDE.md b/CLAUDE.md index cab60837..834429be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,13 @@ codealmanac is a living wiki for codebases, maintained by AI coding agents. It documents what the code can't say — decisions, flows, invariants, incidents, gotchas — as atomic, interlinked markdown pages living in the repo-local `almanac/` tree. New Python repos use `almanac/` only. There is no `docs/almanac/`, `.almanac/`, custom-root, or old CLI compatibility path. Primary consumer is the AI coding agent; humans benefit secondarily. +**Trust order: current code > Almanac > ordinary repository documentation.** + +When code and the Almanac disagree, trust the code. When the Almanac and +ordinary documentation disagree, trust the Almanac. Do not prefer ordinary +docs over the Almanac; the Almanac is maintained more regularly and is more +authoritative. + **Historical spec:** `/Users/rohan/Desktop/Projects/openalmanac/docs/ideas/codebase-wiki.md` — useful background, not current product truth where it conflicts with `notes.md` and `implementation-tickets.md`. Read it before making design changes, then apply the current reset decisions in this repo. ## Python rewrite architecture references diff --git a/README.md b/README.md index 6e3eefda..5a4c6327 100644 --- a/README.md +++ b/README.md @@ -25,28 +25,12 @@ indexed locally, and reviewed in Git like any other code change. ## Quickstart -Give this prompt to your coding agent: - -```text -Install and set up CodeAlmanac for this repository. - -Use the official installer: -curl -fsSL https://codealmanac.com/install.sh | sh - -Then run `codealmanac setup` without `--yes`. Walk me through its interactive -setup and wait for my choices instead of selecting defaults for me. - -Initialize this repository if it does not already have an almanac/ directory, -then verify the installation with `codealmanac doctor`. -``` - -Prefer to do it yourself? - ```bash -curl -fsSL https://codealmanac.com/install.sh | sh && codealmanac setup +uv tool install codealmanac@latest +codealmanac setup ``` -See [Install](#install) and [Setup](#setup) for more options. +See [Setup](#setup) for configuration options. Once CodeAlmanac is set up: @@ -58,66 +42,6 @@ codealmanac show getting-started # Opens one page in the terminal codealmanac serve # Shows the wiki in local web viewer. ``` -## Install - -With the install script: - -```bash -curl -fsSL https://codealmanac.com/install.sh | sh -``` - -or directly: - -```bash -uv tool install codealmanac@latest -``` - -or: - -```bash -python -m pip install codealmanac -``` - -From this checkout: - -```bash -uv sync -uv run codealmanac --help -``` - -Requires Python 3.12+. - -## Migrating From The npm CLI - -The legacy `codealmanac` npm package is retired. PyPI is the only supported -distribution. If you used the npm CLI, your machine may still carry the old -global install plus the hooks and agent instructions it set up. Remove those -before installing from PyPI. - -The easiest path is to hand this prompt to your coding agent: - -```text -Migrate this machine from the old codealmanac npm CLI to the new PyPI CLI. - -1. Uninstall every old install of the CLI and its dependencies: - npm uninstall -g codealmanac - Also check bun, pnpm, and yarn global installs, and remove any stray - codealmanac, almanac, or alm binaries left on PATH. -2. Remove everything the old CLI installed into agent tooling: - - Delete codealmanac hooks from Claude Code settings - (~/.claude/settings.json and any project .claude/settings*.json). - - Remove codealmanac sections and @-imports from ~/.claude/CLAUDE.md and - any other agent instruction files. -3. Install the latest PyPI CLI: - curl -fsSL https://codealmanac.com/install.sh | sh -4. Run codealmanac setup --yes to reinstall agent instructions and automation. -5. Verify the migration: - which codealmanac points at the new install and codealmanac --help runs. - -Leave repo-local almanac/ wiki trees alone - they are committed wiki content, -not part of the CLI install. -``` - ## Setup Install global agent instructions for the local tools you use: @@ -134,17 +58,26 @@ codealmanac setup --yes --runner claude ``` Setup installs agent instructions for your chosen tools and three local macOS -`launchd` jobs. Nothing runs in the cloud. +`launchd` jobs. The jobs and all wiki work run locally. | Job | Default schedule | What it does | | --- | ---: | --- | | Sync | Every 5 hours | Scans recent Codex and Claude conversations and queues useful knowledge for the relevant registered wiki. | -| Garden | Every 4 hours | Reviews every registered wiki for stale, duplicated, or poorly connected knowledge. | +| Garden | Every 24 hours | Reviews every registered wiki for stale, duplicated, or poorly connected knowledge. | | Update | Every 24 hours | Checks for and installs CodeAlmanac CLI updates when it is safe to do so. | These schedules run locally in the background. Use `codealmanac automation status` to see what is installed. +The final setup step asks about anonymous telemetry and recommends Yes so we can +see which commands work and where the CLI breaks. It sends controlled command +and lifecycle outcomes plus sanitized unhandled crashes under a random install +UUID. It never sends code, paths, arguments, queries, prompts, transcripts, +repository/run IDs, locals, or credentials; GeoIP is disabled. Choose No in +setup, pass `setup --no-telemetry`, set `telemetry.enabled` to `false`, or use +`DO_NOT_TRACK=1` at any time. Without a future login, the UUID profile has no +name or email. + If you don't have Codex or prefer Claude, use `--runner claude`. `--target` only chooses which global agent instruction files to install; it does @@ -223,8 +156,9 @@ leads, duplicate pages, and unsupported claims. No-op is valid. If the material adds no durable wiki knowledge, the harness should leave the wiki unchanged. -`ingest` and `garden` create queued runs and start a local worker. Use -`codealmanac jobs` and `codealmanac jobs attach ` to follow them. +`init`, `ingest`, and `garden` create queued runs and start a local worker. To +follow them visually, run `codealmanac serve` and select **Jobs** in the +sidebar. To stay in the terminal, use `codealmanac jobs attach `. ## Sync And Automation @@ -253,7 +187,7 @@ codealmanac automation status # Change a schedule codealmanac config set automation.sync.every 5h -codealmanac config set automation.garden.every 4h +codealmanac config set automation.garden.every 24h codealmanac config set automation.update.every 24h # Disable or re-enable a schedule @@ -382,7 +316,7 @@ every = "5h" [automation.garden] enabled = true -every = "4h" +every = "24h" [automation.update] enabled = true @@ -416,9 +350,31 @@ codealmanac serve ``` The viewer is read-only. It renders pages, search, topics, backlinks, and -file-reference navigation from local wiki data. By default it can switch across -available registered local wikis. Use `codealmanac serve --wiki ` to -narrow the viewer to one wiki. +file-reference navigation from local wiki data. `serve` opens it in your default +browser once the server is ready; use `codealmanac serve --no-open` for headless +or scripted use. By default the viewer can switch across available registered +local wikis. Use `codealmanac serve --wiki ` to narrow it to one wiki. + +## Migrating From The npm CLI + +The legacy `codealmanac` npm package is retired. PyPI is the only supported +distribution. If you used the npm CLI, your machine may still carry the old +global install plus the hooks and agent instructions it set up. Remove those +before installing from PyPI. + +Remove the old global package and any CodeAlmanac hooks or agent-instruction +sections it installed, then install and set up the Python CLI: + +```bash +npm uninstall -g codealmanac +uv tool install codealmanac@latest +codealmanac setup --yes +codealmanac doctor +``` + +Also remove old bun, pnpm, or yarn installs and any stray legacy binaries from +`PATH`. Leave repo-local `almanac/` trees alone; they are committed wiki +content, not part of the CLI install. ## Troubleshooting @@ -459,6 +415,7 @@ default harness. `codealmanac doctor` reports harness availability. This rewrite is local-only for now. - Public command: `codealmanac` +- Short alias: `ca` - Repo wiki root: `almanac/` only - Alternate repo wiki roots: none - User state root: `~/.codealmanac/` @@ -466,10 +423,12 @@ This rewrite is local-only for now. - Storage: local markdown plus derived state under `~/.codealmanac/` - No hosted login/connect/upload commands. - No public SDK or MCP package. -- No compatibility aliases. +- No legacy compatibility aliases beyond the supported `ca` shorthand. - No alternate wiki roots. -- No hidden cloud write path. -- No second wiki command name. +- Optional anonymous usage and sanitized crash telemetry; disable it with + `codealmanac config set telemetry.enabled false` or `--no-telemetry` in setup. +- No wiki, source, prompt, transcript, path, or command-argument upload path. +- No second canonical product name. This is the Python/PyPI product surface. Hosted integration can be added later around the same repo-owned wiki artifact, but it is not part of this release diff --git a/RELEASE.md b/RELEASE.md index fb5a3981..01c70656 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -101,10 +101,11 @@ Do not reuse a published version number. ## Package Surface -The package must expose exactly one public command: +The package must expose the canonical command and its short alias: ```text codealmanac +ca ``` The published artifact must include: @@ -119,7 +120,7 @@ The published artifact must include: The published artifact must not introduce: -- public `almanac` or `alm` commands +- public `almanac`, `alm`, or other undeclared command aliases - public `capture`, `login`, `connect`, or `upload` commands - public SDK or MCP modules - npm, Node, or hosted-dashboard install instructions diff --git a/almanac/architecture/README.md b/almanac/architecture/README.md index c125772b..5efaf871 100644 --- a/almanac/architecture/README.md +++ b/almanac/architecture/README.md @@ -62,14 +62,10 @@ sources: type: wiki path: architecture/cli/terminal-output note: Architecture page for human and JSON terminal rendering. - - id: harness-contract + - id: agent-runs type: wiki - path: architecture/agent-runs/harness-contract - note: Architecture page for normalized harness execution. - - id: provider-adapters - type: wiki - path: architecture/agent-runs/provider-adapters - note: Architecture page for Codex and Claude provider adapters. + path: architecture/agent-runs + note: Architecture hub for the normalized harness contract and the Yoke harness boundary. - id: source-runtime type: wiki path: architecture/sources/source-resolution-and-runtime @@ -102,6 +98,14 @@ sources: type: wiki path: architecture/setup/automation-and-update note: Architecture page for setup-owned automation and update behavior. + - id: telemetry + type: wiki + path: architecture/telemetry + note: Architecture page for opt-in product telemetry, event allowlists, identity storage, and sender boundaries. + - id: instruction-install + type: wiki + path: architecture/setup/instruction-installation + note: Architecture page for writing kernel instructions into a repository during setup. --- # Architecture @@ -134,8 +138,8 @@ For authored wiki contracts, read [Page identity](wiki/page-identity), [Path nor ## Edges And Interfaces -Use [CLI adapter boundary](cli/adapter-boundary) and [Terminal output](cli/terminal-output) for command entrypoints and rendering [@cli-adapter] [@terminal-output]. Use [Harness contract](agent-runs/harness-contract) and [Provider adapters](agent-runs/provider-adapters) for Codex and Claude execution boundaries [@harness-contract] [@provider-adapters]. Use [Source resolution and runtime](sources/source-resolution-and-runtime) when changing ingest inputs or source adapters [@source-runtime]. +Use [CLI adapter boundary](cli/adapter-boundary) and [Terminal output](cli/terminal-output) for command entrypoints and rendering [@cli-adapter] [@terminal-output]. Use [Agent runs](agent-runs/) for normalized harness execution and the Yoke harness boundary that serves Claude and Codex behind one adapter [@agent-runs]. Use [Source resolution and runtime](sources/source-resolution-and-runtime) when changing ingest inputs or source adapters [@source-runtime]. -[Agents and manuals](runtime-resources/prompts-and-manuals) covers the packaged Yoke agents and writing references used by lifecycle runs, and [Setup automation and update](setup/automation-and-update) covers setup-owned scheduler and update behavior [@agents-manuals] [@setup-automation]. +[Agents and manuals](runtime-resources/prompts-and-manuals) covers the packaged Yoke agents and writing references used by lifecycle runs, [Setup automation and update](setup/automation-and-update) covers setup-owned scheduler and update behavior, and [Telemetry](telemetry) covers the narrow remote product-signal exception [@agents-manuals] [@setup-automation] [@telemetry]. [Instruction installation](setup/instruction-installation) covers the sibling setup concern: writing `CLAUDE.md`/`AGENTS.md` kernel instructions into a repository [@instruction-install]. [Local state](repositories/local-state), [Selection and root](repositories/selection-and-root), and [SQLite store boundaries](persistence/sqlite-store-boundaries) explain repository selection, runtime paths, and persistence ownership [@local-state] [@selection-root] [@sqlite-stores]. [Local viewer](viewer/local-viewer) explains the browser UI that projects the same wiki, topics, files, and jobs [@local-viewer]. diff --git a/almanac/architecture/agent-runs/README.md b/almanac/architecture/agent-runs/README.md new file mode 100644 index 00000000..56d4aaa6 --- /dev/null +++ b/almanac/architecture/agent-runs/README.md @@ -0,0 +1,33 @@ +--- +title: Agent Runs +topics: [architecture, agent-runs, harnesses, overview] +sources: + - id: harness-contract + type: file + path: almanac/architecture/agent-runs/harness-contract.md + note: Architecture page for the normalized harness contract used by lifecycle workflows. + - id: provider-adapters + type: file + path: almanac/architecture/agent-runs/provider-adapters.md + note: Architecture page for the Yoke harness boundary that implements the contract. + - id: topics + type: file + path: almanac/topics.yaml + note: Topic graph entry for the agent-runs neighborhood and its harnesses and yoke children. +--- + +# Agent Runs + +Agent runs is the part of CodeAlmanac that executes one build, ingest, or garden task against an external coding agent and turns the result into durable, provider-neutral facts. The neighborhood has two pages: the contract lifecycle workflows depend on, and the one adapter that currently implements it [@topics]. + +Read this hub when changing how a `RunHarnessRequest` is built, how a harness run reports readiness or results, or how provider events become normalized job-log events. + +## Reading Order + +Start with [Harness contract](harness-contract). It defines `RunHarnessRequest`, `HarnessRunResult`, and `HarnessEvent`, and states the boundary rule that lifecycle workflows may depend only on those normalized shapes [@harness-contract]. + +Then read [Yoke harness boundary](provider-adapters). It explains `YokeHarnessAdapter`, the single provider integration that implements the contract for both Claude and Codex by loading a packaged Yoke agent and projecting Yoke runs and events into the contract's models [@provider-adapters]. + +## Neighboring Pages + +[Operation runner](../lifecycle/operation-runner) is the caller: it prepares a `RunHarnessRequest`, invokes the harness through `HarnessesService`, and records the normalized result and events into the [run ledger](../../concepts/run-ledger). [Harness event shape](../../reference/harness-event-shape) is the exact reference for event kinds and fields. [Add a harness provider adapter](../../guides/add-a-harness-provider-adapter) covers the ordered steps for changing or extending this boundary. diff --git a/almanac/architecture/agent-runs/harness-contract.md b/almanac/architecture/agent-runs/harness-contract.md index 4b20f724..714cdfda 100644 --- a/almanac/architecture/agent-runs/harness-contract.md +++ b/almanac/architecture/agent-runs/harness-contract.md @@ -24,23 +24,23 @@ sources: # Harness Contract -The harness contract is the boundary between lifecycle workflows and external agent providers. Build, ingest, and garden send one normalized `RunHarnessRequest` to a selected harness and receive one normalized `HarnessRunResult` back. The workflows do not know whether the provider is Codex or Claude; that provider-specific behavior lives behind [provider adapters](provider-adapters) [@harness-ports] [@harness-requests]. +The harness contract is the boundary between lifecycle workflows and external agent providers. Build, ingest, and garden send one normalized `RunHarnessRequest` to a selected harness and receive one normalized `HarnessRunResult` back. The workflows do not know whether the provider is Codex or Claude; that provider-specific behavior lives behind the [Yoke harness boundary](provider-adapters) [@harness-ports] [@harness-requests]. -The contract matters because lifecycle operations need stable facts after an agent run: readiness, terminal status, output text, optional changed files, transcript references, and normalized events. Those facts feed the [operation runner](../lifecycle/operation-runner), the run ledger, and user-facing job logs without leaking provider JSON streams into the rest of the system [@harness-results] [@harness-events]. +The contract matters because lifecycle operations need stable facts after an agent run: readiness, terminal status, output text, optional changed files, transcript references, and normalized events. Those facts feed the [operation runner](../lifecycle/operation-runner), the [run ledger](../../concepts/run-ledger), and user-facing job logs without leaking provider JSON streams into the rest of the system [@harness-results] [@harness-events]. ## Adapter Shape A harness adapter has one `kind`, a `check()` method, and a `run()` method [@harness-ports]. `check()` reports local readiness without starting an agent. `run()` executes one agent task and returns a normalized result [@harness-ports]. -`HarnessesService` indexes adapters by kind, rejects duplicate adapters, and provides the single service entrypoint used by workflows [@harness-service]. Before running an adapter, it calls `ensure_ready`; unavailable harnesses fail early with a message that can include a repair hint and a command to switch to another registered harness [@harness-service]. +`HarnessesService` indexes adapters by kind and rejects duplicate adapters [@harness-service]. Workflows call `ensure_ready` and `run_ready` as separate stages: readiness failures can include a repair hint and a command to switch harnesses, while every exception from the approved adapter invocation is a provider-execution failure. `run_ready` also wraps caller event-sink failures in `HarnessEventSinkFailed`, keeping local run-log persistence failures distinct from provider failures that occur during the same call [@harness-service]. The supported harness kinds are currently `codex` and `claude`. The terminal run statuses are `succeeded`, `failed`, and `cancelled` [@harness-kinds]. ## Run Requests -`RunHarnessRequest` is small on purpose. It carries the harness kind, model, working directory, prompt, and optional title [@harness-requests]. The prompt is already rendered by the lifecycle workflow before it crosses this boundary. That keeps prompt composition in runtime resources and operation workflows, while adapters focus on provider execution. +`RunHarnessRequest` is small on purpose. It carries the harness kind, model, agent kind, working directory, prompt, and optional title [@harness-requests]. The agent kind is one of `build`, `ingest`, or `garden`; it tells the adapter which packaged Yoke agent to load for the run, so one adapter can serve every lifecycle operation instead of branching per operation [@harness-requests]. The prompt is already rendered by the lifecycle workflow before it crosses this boundary. That keeps prompt composition in runtime resources and operation workflows, while adapters focus on provider execution. -The request requires non-empty model and prompt text [@harness-requests]. A harness adapter may translate the request into provider options, CLI arguments, SDK calls, or app-server messages, but it must return to the same result model. +The request requires non-empty `model` and `prompt` text; the harness contract does not re-validate model names against a catalog [@harness-requests]. The controlled model set that constrains which names ever reach this request is enforced earlier, in config; see [Controlled model catalog](../../decisions/controlled-model-catalog) for the allowed models and why the set is closed. A harness adapter may translate the request into provider options, CLI arguments, SDK calls, or app-server messages, but it must return to the same result model. ## Results @@ -56,6 +56,6 @@ Events also carry structured details when available: tool display data, token us ## Boundary Rule -The harness contract is a normalization boundary. Provider adapters may deal with CLI auth checks, JSON-RPC streams, SDK message classes, sandbox settings, and provider-specific failures. Service and workflow code should only depend on `HarnessReadiness`, `RunHarnessRequest`, `HarnessRunResult`, and `HarnessEvent` [@harness-service] [@harness-results]. +The harness contract is a normalization boundary. The [Yoke harness boundary](provider-adapters) may deal with agent selection, provider run options, sandbox and permission policy, and Yoke-specific failures. Service and workflow code should only depend on `HarnessReadiness`, `RunHarnessRequest`, `HarnessRunResult`, and `HarnessEvent` [@harness-service] [@harness-results]. -That rule keeps adding a provider additive: implement the adapter, map provider events into the normalized event shape, populate result metadata that the provider can support, and register the adapter with the service. The lifecycle workflows should not gain provider branches. +That rule keeps the boundary additive: mapping provider events into the normalized event shape and populating result metadata stays inside the adapter, so the lifecycle workflows never gain provider branches. diff --git a/almanac/architecture/agent-runs/provider-adapters.md b/almanac/architecture/agent-runs/provider-adapters.md index c34200ec..4ae7d607 100644 --- a/almanac/architecture/agent-runs/provider-adapters.md +++ b/almanac/architecture/agent-runs/provider-adapters.md @@ -1,6 +1,6 @@ --- title: Yoke Harness Boundary -topics: [architecture, harnesses, providers, agent-runs] +topics: [architecture, harnesses, yoke, agent-runs] sources: - id: adapter type: file @@ -17,6 +17,12 @@ sources: - id: contract type: file path: src/codealmanac/services/harnesses/ports.py + - id: settings + type: file + path: src/codealmanac/settings.py + - id: app + type: file + path: src/codealmanac/app.py - id: tests type: file path: tests/test_yoke_harness_integration.py @@ -36,6 +42,23 @@ default Claude surface. It loads the requested build, ingest, or garden agent from the packaged Yoke collection, forwards the task prompt unchanged, and applies the trusted non-interactive permission and timeout policy [@adapter]. +## Runtime Root + +`YokeHarnessAdapter` also requires a `runtime_root: Path` at construction. +`default_harness_adapters(runtime_root)` receives `LocalStatePaths.harness_runtime_dir` +(`state_dir / "harnesses"`, so `~/.codealmanac/harnesses` by default) from the +composition root and passes it to both the Claude and Codex adapters [@defaults] +[@settings] [@app]. The adapter forwards `runtime_root` into `yoke.Harness(...)` +on every `check()` and `run()` call, so Yoke's native provider compilation caches +under CodeAlmanac's own local state instead of writing `.codex` or `.claude` +configuration into the target repository [@adapter]. + +`check()` cannot run a real repository task, so it uses a sibling working +directory instead of the caller's `cwd`: `runtime_root.parent / "readiness"` +(`~/.codealmanac/readiness` by default), created on demand [@adapter] [@tests]. +This keeps readiness probes off whatever directory the CLI happens to be +invoked from. + ## What It Does Not Own The boundary does not implement Claude or Codex protocols, discover provider @@ -64,10 +87,11 @@ CLI and viewer on CodeAlmanac's stable event vocabulary [@events]. ## Failure Modes -Missing tools, authentication failures, timeouts, and Yoke errors become -unavailable readiness or failed normalized runs. Unknown future Yoke event kinds -remain serializable as `unknown`; malformed provider JSON does not cross into -workflow services [@adapter] [@events] [@results]. +Missing tools, authentication failures, timeouts, Pydantic validation errors +from malformed Yoke options, and Yoke errors become unavailable readiness or +failed normalized runs. Unknown future Yoke event kinds remain serializable as +`unknown`; malformed provider JSON does not cross into workflow services +[@adapter] [@events] [@results]. ## How To Change It diff --git a/almanac/architecture/cli/adapter-boundary.md b/almanac/architecture/cli/adapter-boundary.md index 1dd59327..363f2058 100644 --- a/almanac/architecture/cli/adapter-boundary.md +++ b/almanac/architecture/cli/adapter-boundary.md @@ -5,7 +5,11 @@ sources: - id: cli-main type: file path: src/codealmanac/cli/main.py - note: CLI entrypoint, parser invocation, app construction, and error handling. + note: CLI entrypoint, parser invocation, and app construction. + - id: cli-execution + type: file + path: src/codealmanac/cli/execution.py + note: Post-parse execution, product-error and interrupt handling, exit codes, and telemetry capture. - id: parser-root type: file path: src/codealmanac/cli/parser/root.py @@ -36,13 +40,15 @@ This boundary is split into three responsibilities: parser modules define public ## Entrypoint -`main()` builds the parser, parses `argv`, and delegates to `dispatch(args)`. It catches `CodeAlmanacError` and Pydantic `ValidationError`, prints `codealmanac: ...` to stderr, and returns exit code `1` [@cli-main]. The `dispatch(args)` helper constructs the app with `create_app()` and passes the parsed arguments plus app object to the root dispatcher [@cli-main]. +`main()` builds the parser, parses `argv`, and constructs the app with `create_app()`. A `CliSyntaxError` at parse time renders a guided syntax screen and returns exit code `2` before the app is ever touched [@cli-main]. Once parsing succeeds, `main()` hands the parsed args, the app, and a start timestamp to `execute()` in `cli/execution.py`, which is the actual product-error boundary [@cli-main] [@cli-execution]. + +`execute()` calls the root `dispatch(args, app)` and translates what comes back into stderr text and an exit code: a `CodeAlmanacError` or Pydantic `ValidationError` prints `codealmanac: ...` to stderr and returns `1`; a `KeyboardInterrupt` is re-raised after capture (exit code `130`); any other exception is captured as a telemetry exception before it propagates [@cli-execution]. `execute()` also calls `capture_command()` after every path, successful or not, which is why command telemetry lives at this layer rather than in `main()` — see [Telemetry](../telemetry) for the event shape. See [Terminal Output](terminal-output) and the [error and exit code contract](../../reference/cli/error-and-exit-code-contract) for how the syntax-error path renders and why it gets its own exit code. That small entrypoint enforces the adapter role. The CLI owns process-level concerns: argument parsing, stderr for user-facing failures, and integer exit codes. It does not construct stores, choose integrations, or run lifecycle logic directly. ## Parser Boundary -`cli/parser/root.py` creates the `codealmanac` parser, installs `--version`, and registers command families through `add_run_commands`, `add_wiki_commands`, and `add_admin_commands` [@parser-root]. The public command metavar lists the visible command surface, including `init`, `ingest`, `garden`, `sync`, `search`, `show`, `topics`, `health`, `validate`, `serve`, `config`, `setup`, `jobs`, and `automation` [@parser-root]. +`cli/parser/root.py` creates the `codealmanac` parser, installs `--version`, and registers command families through `add_run_commands`, `add_wiki_commands`, and `add_admin_commands` [@parser-root]. The public command metavar lists the full visible command surface: `init`, `ingest`, `garden`, `sync`, `list`, `search`, `show`, `topics`, `health`, `validate`, `reindex`, `serve`, `tag`, `untag`, `config`, `setup`, `uninstall`, `doctor`, `update`, `jobs`, and `automation` [@parser-root]. Parser modules should describe syntax only. For example, the root parser knows that command families exist, but it does not know how search calls the index or how setup writes instructions. That keeps public command shape separate from product action. diff --git a/almanac/architecture/cli/terminal-output.md b/almanac/architecture/cli/terminal-output.md index 59ed95a7..ce681524 100644 --- a/almanac/architecture/cli/terminal-output.md +++ b/almanac/architecture/cli/terminal-output.md @@ -18,10 +18,18 @@ sources: type: file path: src/codealmanac/cli/render/search.py note: Search and reindex render behavior. + - id: run-render + type: file + path: src/codealmanac/cli/render/run_commands.py + note: Human and JSON receipts for queued lifecycle work. - id: cli-tests type: file path: tests/test_cli.py - note: CLI tests that assert terminal text, syntax screens, stderr, JSON output, and exit behavior. + note: CLI tests that assert terminal text, stderr, JSON output, and exit behavior. + - id: cli-syntax-tests + type: file + path: tests/test_cli_syntax.py + note: Tests for the blue syntax error screens, replacement hints, and command-guide tables. - id: architecture-tests type: file path: tests/test_architecture.py @@ -42,6 +50,14 @@ Search output shows the pattern. `render_search` prints JSON rows when `--json` The tests lock this in. They check that `search login` prints a slug, `search login --slugs` suppresses the summary, missing search results produce no stdout and `# 0 results` on stderr, and `reindex --json` includes structured fields such as `pages_indexed` [@cli-tests]. +Queued lifecycle receipts use a different hierarchy because their main purpose +is to teach the next action. The human output for `init`, `ingest`, and `garden` +leads with an operation-specific background-work headline, gives +`codealmanac serve` and the **Jobs** sidebar destination one shared blue +bordered callout, and then offers `jobs attach` as the terminal alternative. +The job id and repository sit in quiet metadata at the bottom. The JSON receipt +remains compact and preserves queue and worker fields for scripts [@run-render]. + ## Styling `cli/render/style.py` centralizes ANSI styling. `use_color()` returns true only when stdout is a TTY and `NO_COLOR` is not set, and every palette property resolves through that check [@style]. This gives the same render functions colored output for humans and plain output for pipes. @@ -54,7 +70,7 @@ Rich terminal UI is kept at the render edge. The architecture test `test_rich_te The CLI treats stdout and stderr deliberately. `main()` prints product and validation errors to stderr with the `codealmanac:` prefix, while renderers choose stderr for advisory or empty-result messages when stdout should stay machine-friendly [@cli-tests] [@search-render]. For example, empty search writes only to stderr, leaving stdout empty [@cli-tests]. -Syntax errors use the same stderr rule. The parser edge raises a shaped syntax problem, and `cli/render/syntax.py` prints a blue CodeAlmanac heading, the command the user typed, an optional replacement, and a table from the command guide [@syntax-render]. This keeps raw parser implementation details out of the user-facing error path. +Syntax errors use the same stderr rule. The parser edge raises a shaped syntax problem, and `cli/render/syntax.py` prints a blue CodeAlmanac heading, the command the user typed, an optional replacement, and a table from the command guide [@syntax-render] [@cli-syntax-tests]. This keeps raw parser implementation details out of the user-facing error path. This distinction supports scripts. A command can emit human warnings without polluting stdout, and `--json` remains the structured piping format described in the styling module docstring [@style]. diff --git a/almanac/architecture/composition-root.md b/almanac/architecture/composition-root.md index 5bc2f70d..e85bcaa2 100644 --- a/almanac/architecture/composition-root.md +++ b/almanac/architecture/composition-root.md @@ -24,9 +24,9 @@ The composition root is `src/codealmanac/app.py`. It is the one place that build ## Adapter Injection -`AppAdapters` is the injection surface for outside systems. It can carry harness adapters, transcript discovery adapters, source runtime adapters, a scheduler, a worker spawner, update command providers, instruction installers, global-state removers, and package uninstallers [@app-root]. Each field is optional, so production construction can use defaults while tests can pass fakes. +`AppAdapters` is the injection surface for outside systems. It can carry harness adapters, transcript discovery adapters, source runtime adapters, a scheduler, a worker spawner, an executor spawner, a process controller, update command providers, instruction installers, global-state removers, and package uninstallers [@app-root]. Each field is optional, so production construction can use defaults while tests can pass fakes. -This design keeps provider selection at the edge. If no harness adapters are supplied, the root uses `default_harness_adapters()`. If no scheduler is supplied, automation uses `LaunchdSchedulerAdapter`. If no source adapters are supplied, the source service receives the default transcript discovery and runtime adapters [@app-root]. The services receive port implementations, not provider-specific branching logic. +This design keeps provider selection at the edge. If no harness adapters are supplied, the root uses `default_harness_adapters(local_state.harness_runtime_dir)`, giving both the Claude and Codex adapters a product-owned cache directory under local state instead of the target repository [@app-root]. If no scheduler is supplied, automation uses `LaunchdSchedulerAdapter`. If no source adapters are supplied, the source service receives the default transcript discovery and runtime adapters [@app-root]. The services receive port implementations, not provider-specific branching logic. See [Yoke harness boundary](agent-runs/provider-adapters) for what the runtime directory is used for. ## Service Assembly @@ -38,9 +38,9 @@ The service graph encodes ownership. `SearchService` depends on repositories and `create_workflows` constructs the [lifecycle workflow](lifecycle/workflows) layer. It creates one [Operation runner](lifecycle/operation-runner) for each run kind: build, ingest, and garden [@app-root]. Each runner receives repositories, harnesses, runs, index, and health [@app-root]. -The workflows then compose services around the shared operation runner. `IngestWorkflow` receives sources, ingest operations, and manuals. `GardenWorkflow` receives index, health, garden operations, and manuals. `BuildWorkflow` receives repositories, wiki, build operations, and manuals [@app-root]. Packaged Yoke agents are loaded at the harness integration edge rather than injected as a prompt-rendering service. This is the bridge to the broader [Service Boundaries](service-boundaries) rule: workflows coordinate services, but the composition root provides the machinery. +The workflows then compose services around the shared operation runner. `IngestWorkflow` receives sources, ingest operations, and manuals. `GardenWorkflow` receives index, health, garden operations, and manuals. `BuildWorkflow` receives only repositories, wiki, and build operations; it does not take a manual dependency, because `WikiService` already materializes repository-local manuals under `almanac/manual/` during initialization and the build runtime payload references that path instead of embedding manual bodies [@app-root]. Packaged Yoke agents are loaded at the harness integration edge rather than injected as a prompt-rendering service. This is the bridge to the broader [Service Boundaries](service-boundaries) rule: workflows coordinate services, but the composition root provides the machinery. See [Agents and manuals](runtime-resources/prompts-and-manuals) for why build's manual delivery now differs from ingest and garden. -`RunQueue` and `SyncWorkflow` are also assembled here. The queue receives repositories, runs, build, ingest, garden, and a worker spawner. Sync receives repositories, sources, the queue, and `SyncStateStore` [@app-root]. That keeps scheduled or queued execution on the same app graph as direct commands; see [Run queue and sync](lifecycle/run-queue-and-sync) for how the queue and sync scanner behave at runtime. +`RunQueue` and `SyncWorkflow` are also assembled here. The queue receives repositories, runs, build, ingest, garden, a worker spawner, a run executor, an executor spawner, a cancellation gate, and telemetry [@app-root]. Telemetry lets `RunQueueWorker` capture structural failures from queued and scheduled runs the same way the CLI edge does; see [Telemetry](telemetry) for the event shape. Sync receives repositories, sources, the queue, and `SyncStateStore` [@app-root]. That keeps scheduled or queued execution on the same app graph as direct commands; see [Run queue and sync](lifecycle/run-queue-and-sync) for how the queue and sync scanner behave at runtime. ## Why It Stays Small diff --git a/almanac/architecture/lifecycle/mutation-safety.md b/almanac/architecture/lifecycle/mutation-safety.md index 54652a57..6f7e787e 100644 --- a/almanac/architecture/lifecycle/mutation-safety.md +++ b/almanac/architecture/lifecycle/mutation-safety.md @@ -42,9 +42,7 @@ That distinction matters for future changes. The prompt policy is still strict: ## Prompt Boundary -`operation_commit_policy` is the source-control contract lifecycle prompts receive. When auto-commit is enabled, it allows committing only `almanac/**/*.md` and `almanac/topics.yaml`; it forbids user config, runtime state, application source, logs, and unrelated files [@commit-policy]. The product decision behind this prompt-facing policy is [Auto-commit is prompt policy](../../decisions/auto-commit-is-prompt-policy). - -When auto-commit is disabled, the same policy tells the agent not to stage files and not to run `git commit` [@commit-policy]. This is policy rendered into the prompt. It is not a Python committer or a Python-side file mutation checker. +`operation_commit_policy` renders the source-control contract lifecycle prompts receive, toggled by the `auto_commit` setting [@commit-policy]. The exact allowed and forbidden files and the enabled/disabled instruction text are recorded in [Auto-commit is prompt policy](../../decisions/auto-commit-is-prompt-policy) rather than repeated here; this page only needs the boundary fact that it is policy rendered into the prompt, not a Python committer or a Python-side file mutation checker [@commit-policy]. ## Runner Boundary diff --git a/almanac/architecture/lifecycle/operation-runner.md b/almanac/architecture/lifecycle/operation-runner.md index 6cd39e31..42d3be4a 100644 --- a/almanac/architecture/lifecycle/operation-runner.md +++ b/almanac/architecture/lifecycle/operation-runner.md @@ -34,9 +34,9 @@ This shape exists so lifecycle workflows do not reimplement harness and run plum ## Harness Execution -`execute` calls `HarnessesService.run` with a `RunHarnessRequest` containing the harness kind, model, repository root, rendered prompt, and optional title [@operation-service]. The runner then records the harness transcript if one exists and converts normalized harness events into run log events [@operation-service]. +`execute` builds a `RunHarnessRequest` containing the harness kind, model, repository root, rendered prompt, and optional title. It then calls `HarnessesService.ensure_ready` before the separate `run_ready` provider invocation [@operation-service]. The runner records the harness transcript if one exists and converts normalized harness events into run log events [@operation-service]. -Event classification lives beside the runner, not in individual workflows. Harness error events become run error events. Tool use, tool result, usage, provider session, warning, and helper-agent events become tool events. Other harness events become output events [@operation-harness]. +Event classification lives beside the runner, not in individual workflows. `text_delta` events are dropped before classification and never become a run event, so incremental streaming text does not bloat the durable run log. Of the remaining events, harness error events become run error events; tool use, tool result, tool summary, usage, provider session, warning, and helper-agent events become tool events; and every other harness event becomes an output event [@operation-harness]. If a harness returns no event stream, the harness helper creates a terminal fallback event from the harness status and output text [@operation-harness]. That keeps failed and minimal harness adapters visible in the run log. @@ -44,14 +44,18 @@ If a harness returns no event stream, the harness helper creates a terminal fall After recording harness output, the runner validates harness success [@operation-service] [@operation-harness]. A failed harness therefore leaves its transcript and output in the run log before the run is marked failed. -The runner then refreshes the index, validates the wiki through `HealthService.ensure_valid`, and finishes the run as `DONE` with the harness summary or operation success summary [@operation-service]. If any step raises, `fail` records a first-line error and tries to finish the run as failed [@operation-service]. +The runner then refreshes the index, validates the wiki through `HealthService.ensure_valid`, and finishes the run as `DONE` with the harness summary or operation success summary [@operation-service]. Each step runs inside an explicit failure phase. A failed readiness check becomes `harness_readiness`; an adapter exception or normalized failed result becomes `provider_execution`; index refresh becomes `indexing`; final health validation becomes `wiki_validation`; and event-sink or run machinery failures become `internal_error` [@operation-service]. `fail` computes a non-throwing one-line summary, then best-efforts the readable error event and authoritative terminal transition independently. It records the supplied phase rather than inferring durable analytics from exception classes or traceback modules. + +The readiness and invocation calls are the stage distinction: missing, unavailable, or failed readiness checks are `harness_readiness`; once readiness passes, adapter exceptions are `provider_execution` regardless of their exception class. `HarnessEventSinkFailed` preserves the one exception to that stage rule by identifying caller-owned event persistence as `internal_error` [@operation-service]. Ingest source resolution and runtime inspection occur before shared execution, so the ingest workflow marks those scopes as `source_preparation` while still delegating the actual failure write to `OperationRunner`. ## Architectural Boundary -The architecture tests enforce this ownership. They require operation service, harness, and commit modules to exist; require `workflows/operations/mutation.py` to be absent; and reject fragments such as `RunHarnessRequest`, `FinishRunRequest`, and harness event helpers inside ingest and garden workflow services [@architecture-tests]. +The architecture tests enforce this ownership. They require operation service, harness, and commit modules to exist; require `workflows/operations/mutation.py` to be absent; and reject fragments such as `RunHarnessRequest`, `FinishRunRequest`, and harness event helpers inside ingest and garden workflow services [@architecture-tests]. Workflow services may name their preparation phase through `OperationRunner.failure_phase`; they still do not write run failures themselves. That guardrail matters for future changes. New page-writing operation kinds should call the runner with their own prompt and context. They should not copy the lifecycle machinery that already lives here. ## Related Safety Page The write boundary for lifecycle agents is covered in [Mutation safety](mutation-safety). That page explains the current split between prompt-level source-control policy and runtime wiki validation. + +When a run fails at one of these steps, [Debug a failed lifecycle run](../../guides/debug-a-failed-lifecycle-run) walks through distinguishing a harness failure from a wiki validation failure using the recorded event stream. diff --git a/almanac/architecture/lifecycle/run-queue-and-sync.md b/almanac/architecture/lifecycle/run-queue-and-sync.md index f004b31c..287dee2c 100644 --- a/almanac/architecture/lifecycle/run-queue-and-sync.md +++ b/almanac/architecture/lifecycle/run-queue-and-sync.md @@ -18,10 +18,18 @@ sources: type: file path: src/codealmanac/integrations/runs/process.py note: Detached worker/executor spawning and verified process-tree termination. + - id: run_control + type: file + path: src/codealmanac/workflows/run_queue/control.py + note: RunCancellation orchestration of intent, termination, and terminal status. - id: run_models type: file path: src/codealmanac/services/runs/models.py note: Run specs, queue drain result, and run statuses. + - id: worker_locks + type: file + path: src/codealmanac/services/runs/worker_locks.py + note: Global worker ownership and atomic idle handoff transaction. - id: sync_workflow type: file path: src/codealmanac/workflows/sync/service.py @@ -52,7 +60,13 @@ Scheduled Garden iterates registered repositories and skips repositories that al For each queued run, the worker starts a hidden `__run-executor ` child and waits for it. `RunExecutor` atomically claims the row with its execution identity, reads the durable spec, and calls `BuildWorkflow.execute_started(...)`, `IngestWorkflow.execute_started(...)`, or `GardenWorkflow.execute_started(...)` [@run_worker] [@run_executor]. The worker reconciles the terminal record before selecting the next row, so cancelling one executor does not kill queue management or strand later work [@run_worker]. -Running cancellation goes through `RunCancellation`: record intent, terminate the verified executor and its descendant harness processes, confirm exit, then append the terminal `cancelled` status. The process adapter validates PID birth time before signaling and escalates from graceful termination to force kill when necessary [@run_processes]. +Running cancellation goes through `RunCancellation`: record intent, terminate the verified executor and its descendant harness processes, confirm exit, then append the terminal `cancelled` status [@run_control]. The process adapter validates PID birth time before signaling and escalates from graceful termination to force kill when necessary [@run_processes]. + +## Atomic Idle Handoff + +Every lifecycle start queues its run and spawns a worker. A spawned worker exits with `lock_acquired=False` when another worker already owns the global drain lock; the owner is responsible for picking up later queued work [@run_queue] [@run_worker]. + +When the owner observes an empty queue, it calls `release_worker_if_idle(...)`. The worker-lock store starts an immediate SQLite transaction, verifies ownership, repeats the canonical oldest-queued-run query, and deletes the lock only if the queue remains empty [@run_worker] [@worker_locks]. Queue insertion uses the same write-transaction boundary. If insertion commits first, the current worker retains the lock and continues; if idle release commits first, the producer's newly spawned worker can acquire the free lock. This closes the empty-check-to-release lost-wakeup window without polling or a permanent daemon. ## Sync Boundary diff --git a/almanac/architecture/lifecycle/workflows.md b/almanac/architecture/lifecycle/workflows.md index 55bf69f0..9924adae 100644 --- a/almanac/architecture/lifecycle/workflows.md +++ b/almanac/architecture/lifecycle/workflows.md @@ -38,7 +38,7 @@ When the worker executes the queued run, build calls the shared operation runner ## Ingest -Ingest is the source-material workflow. It starts an `INGEST` run for a selected repository, resolves the requested inputs into source briefs, loads runtime snapshots for those sources, records those preparation steps, and renders the ingest prompt [@ingest-workflow]. +Ingest is the source-material workflow. It starts an `INGEST` run for a selected repository, resolves the requested inputs into source briefs, loads runtime snapshots for those sources, records those preparation steps, and renders the ingest prompt [@ingest-workflow]. Resolution and runtime-inspection failures are durably categorized as `source_preparation`, distinct from failures after the harness starts. See [Source resolution and runtime](../sources/source-resolution-and-runtime) for how selected [source material](../../concepts/source-material) becomes those briefs and snapshots. The source runtime layer is important because ingest should give the agent concrete material, not just file names or user prose. The workflow also passes the almanac root as an ignored directory when inspecting source runtime, so the existing wiki is not accidentally treated as input material for the ingest source set [@ingest-workflow]. @@ -54,6 +54,6 @@ Garden does not resolve external source material. Its job is to improve structur ## Shared Contract -All page-writing workflows end at the operation runner. The runner marks the run running, invokes the harness, records transcript events, validates harness success, refreshes the index, validates the wiki, and only then marks the run done [@operation-runner]. +All page-writing workflows end at the operation runner. The runner marks the run running, invokes the harness, records transcript events, validates harness success, refreshes the index, validates the wiki, and only then marks the run done [@operation-runner]. Workflows name their own preparation phases; the runner names the shared execution phases and remains the single failure writer. When changing a workflow, preserve that division. Workflow services should decide what context and prompt the agent receives. Shared run state, harness recording, indexing, and final validation belong in the operation path. diff --git a/almanac/architecture/persistence/sqlite-store-boundaries.md b/almanac/architecture/persistence/sqlite-store-boundaries.md index 69cc78a4..f0480d05 100644 --- a/almanac/architecture/persistence/sqlite-store-boundaries.md +++ b/almanac/architecture/persistence/sqlite-store-boundaries.md @@ -32,6 +32,6 @@ These helpers are generic. They do not know about repositories, runs, topics, or ## Store-Owned Schemas -The index schema lives with the index service and defines derived tables for pages, topics, topic parents, file references, page sources, page links, cross-wiki links, FTS rows, and metadata [@index_schema]. The run tables live with the run service and define `runs`, `run_events`, and `worker_locks` [@run_tables]. +The index schema lives with the index service and defines derived tables for pages, topics, topic parents, file references, page sources, page links, cross-wiki links, page sections, an `fts_sections` full-text search table, and metadata [@index_schema]. [Index refresh and search](../wiki/index-refresh-and-search) explains how page sections are projected and ranked. The run tables live with the run service and define `runs`, `run_events`, and `worker_locks` [@run_tables]. [Run queue and sync](../lifecycle/run-queue-and-sync) explains the atomic handoff semantics the `worker_locks` table enforces. The composition root wires concrete stores into their owning services [@app_root]. Future persistence work should keep that direction: add or change the store that owns the data, then inject it through the app root rather than opening SQLite from adapters or workflows. diff --git a/almanac/architecture/repositories/local-state.md b/almanac/architecture/repositories/local-state.md index 27925d8e..de01b95f 100644 --- a/almanac/architecture/repositories/local-state.md +++ b/almanac/architecture/repositories/local-state.md @@ -36,7 +36,7 @@ sources: Repository local state is the runtime state CodeAlmanac keeps outside the committed wiki. The authored wiki lives under `almanac/`, while the global database, user config, update lock, and per-repository runtime directories live under the product-specific state directory `~/.codealmanac/` [@repo-readme] [@core-paths]. This split lets Markdown stay reviewable in Git while search indexes, run records, and other derived state can be rebuilt or managed locally. -The local-state boundary is owned by `AppConfig` and `LocalStatePaths`. `AppConfig` defaults the database to `~/.codealmanac/codealmanac.db` and the user config to `~/.codealmanac/config.toml`; both paths are normalized through `expanduser().resolve(strict=False)` [@settings] [@core-paths]. `LocalStatePaths` then derives the state directory from the database parent and rejects a database path that is not directly inside that state directory [@settings]. +The local-state boundary is owned by `AppConfig` and `LocalStatePaths`. The composition root builds one `LocalStatePaths` value from `AppConfig` and threads it through every service that needs a local file or database path, so repository registration, indexing, and updates all agree on the same directory without recomputing it [@settings] [@app-root]. [Local state layout](../../reference/local-state-layout) is the reference for the exact default paths, the path-normalization rule, and the constraint that the database must live directly inside the state directory. ## What Lives There @@ -52,9 +52,7 @@ The local database is also the lookup surface for command flows. The repository ## Config And Locks -User config is local state, not repo source. `AppConfig` reads `CODEALMANAC_` environment variables and otherwise uses the default config path under `~/.codealmanac/` [@settings]. If only the database path is customized, `LocalStatePaths.from_config(...)` keeps config beside that database unless the config path was explicitly set [@settings]. - -The update lock is also global state. `LocalStatePaths.update_lock_path` returns `~/.codealmanac/update.lock`, and the app composition root passes that path to `UpdatesService` with the local database path [@settings] [@app-root]. That shape makes scheduled package update coordination a global product concern rather than a wiki-file concern. +User config and the update lock are global state, not repo source, for the same reason repository rows are: a single machine-local `AppConfig` should govern every registered repository instead of each repo carrying its own settings [@settings]. The app composition root passes `LocalStatePaths.update_lock_path` to `UpdatesService` alongside the local database path, which makes scheduled package update coordination a global product concern rather than a wiki-file concern [@settings] [@app-root]. See [Local state layout](../../reference/local-state-layout) for the exact config-path derivation rule and the update-lock path. ## Boundary With The Wiki diff --git a/almanac/architecture/runtime-resources/prompts-and-manuals.md b/almanac/architecture/runtime-resources/prompts-and-manuals.md index 1160ca2a..1b81a4d2 100644 --- a/almanac/architecture/runtime-resources/prompts-and-manuals.md +++ b/almanac/architecture/runtime-resources/prompts-and-manuals.md @@ -1,6 +1,6 @@ --- title: Agents And Manuals -topics: [architecture, runtime-resources, manuals, yoke] +topics: [architecture, runtime-resources, yoke, prompts] sources: - id: catalog type: file @@ -56,6 +56,13 @@ the build agent starts [@wiki-service] [@manual-library]. The build runtime payload names that repository-local manual root, and the build instructions give writing sub-agents exact paths beneath it [@build-service] [@build-agent]. +Ingest and garden do not use this repository-local manual root. Their runtime +payloads still embed the full manual document bodies from `ManualLibrary` +directly as `manual_documents` [@ingest-service] [@garden-service]. Only build +was moved to filesystem-reference manuals; a future change that extends this +pattern to ingest and garden needs to touch both workflow services and their +agent instructions the same way the build workflow was changed. + ## What It Does Not Own Agent folders do not contain Python orchestration or provider configuration. diff --git a/almanac/architecture/setup/automation-and-update.md b/almanac/architecture/setup/automation-and-update.md index 8524cafb..0be2ba3b 100644 --- a/almanac/architecture/setup/automation-and-update.md +++ b/almanac/architecture/setup/automation-and-update.md @@ -1,6 +1,6 @@ --- title: Setup Automation And Update -topics: [architecture, setup, automation] +topics: [architecture, setup, automation, telemetry] sources: - id: app type: file @@ -18,6 +18,14 @@ sources: type: file path: src/codealmanac/services/setup/automation.py note: Setup-side automation task selection. + - id: setup_wizard + type: file + path: src/codealmanac/cli/dispatch/setup_tui.py + note: Interactive onboarding choices and selected-notice wiring. + - id: setup_background_items + type: file + path: src/codealmanac/cli/render/setup/background_items.py + note: macOS background-item selection and explanation before and after setup. - id: automation_service type: file path: src/codealmanac/services/automation/service.py @@ -52,7 +60,7 @@ sources: Setup, automation, and update form CodeAlmanac's machine-level maintenance layer. `setup` installs local agent instructions and writes one user configuration update. The config service then makes launchd match that saved automation policy. `automation` owns recurring launchd jobs, while `update` owns package-manager upgrades for the installed CLI [@setup_service][@automation_service][@updates]. -The area matters because it is local-only product infrastructure. Scheduled work runs local CodeAlmanac task entrypoints for sync, Garden, and update; it does not connect to a hosted service or perform cloud capture [@live_agreement]. Runtime state and scheduler logs belong under the user's machine state, while repository wiki source remains under `almanac/` [@live_agreement]. +The area matters because scheduled work itself remains local product infrastructure. Sync, Garden, and update run local CodeAlmanac entrypoints. When telemetry is enabled, those entrypoints may emit allowlisted command/lifecycle outcomes or sanitized unhandled exceptions, but they never upload wiki, source, prompt, or transcript content [@live_agreement]. Runtime state and scheduler logs belong under the user's machine state, while repository wiki source remains under `almanac/` [@live_agreement]. For the telemetry boundary, see [Telemetry](../telemetry). For the operator-facing install and troubleshooting path, see [Setup local automation](../../guides/setup-local-automation). ## Setup Boundary @@ -62,13 +70,17 @@ The request model defaults to both instruction targets, Codex as the harness, au Setup's automation policy lives outside the service. The default tasks are sync, Garden, and update. `selected_setup_tasks` reflects the three enable/disable choices, which are persisted in TOML before scheduler reconciliation [@setup_automation]. +Interactive onboarding explains Sync and Garden where automatic wiki maintenance is selected. On the Product updates step, it also shows a choice-sensitive macOS heads-up before installation: automatic maintenance contributes Sync and Garden, and automatic product updates contributes Update. The notice tells the user that macOS may show one “Background Items Added” notification per enabled task and identifies every item as CodeAlmanac-owned. The completion screen repeats the enabled task count and names after setup [@setup_wizard][@setup_background_items]. + ## Scheduled Jobs `AutomationService` reconciles one task, removes all tasks during full uninstall, and reports status through a `SchedulerAdapter` port [@automation_service]. Reconciliation installs a job when enabled and uninstalls it when disabled. `AutomationJobFactory` turns saved task policy into a `ScheduledJob` [@automation_service][@automation_jobs]. -The job factory gives each task concrete local execution details. The resolved `codealmanac` executable runs `sync`, `update --scheduled`, or `__garden-scheduler` [@automation_jobs]. Intervals come from saved user configuration; defaults are 5 hours, 4 hours, and 24 hours [@automation_jobs]. +The job factory gives each task concrete local execution details. The resolved `codealmanac` executable runs `sync`, `update --scheduled`, or `__garden-scheduler` [@automation_jobs]. Intervals come from saved user configuration; defaults are 5 hours for sync and 24 hours for both Garden and update [@automation_jobs]. -The macOS implementation writes launchd plists under `~/Library/LaunchAgents`, creates stdout and stderr log directories, bootouts any existing job, bootstraps the new job, and reads status back from launchd [@launchd]. The generated plist contains the label, program arguments, start interval, environment variables, `RunAtLoad`, and log paths [@launchd]. This keeps the service boundary scheduler-neutral while the adapter owns launchd mechanics. +The macOS implementation writes launchd plists under `~/Library/LaunchAgents`, creates stdout and stderr log directories, bootouts any existing job, bootstraps the new job, and reads status back from launchd [@launchd]. The generated plist contains the label, a `Program` key set to the resolved `codealmanac` executable, program arguments, start interval, environment variables, `RunAtLoad`, and log paths [@launchd]. This keeps the [service boundary](../service-boundaries) scheduler-neutral while the adapter owns launchd mechanics. + +`launchctl print` output backs richer status than install/uninstall alone. The adapter parses that output into a `ScheduledJobState` of `running`, `idle`, or `unknown`, plus an optional run count, last exit code, and current PID [@launchd]. `automation status` renders those fields when present: whether launchd loaded the job, its running/idle state, how many times it has run, whether the last run succeeded or failed with its exit code, and its PID while active [@launchd]. An idle state between scheduled intervals is normal; a nonzero last exit code means the last run failed and its log under `~/.codealmanac/logs/` is the next thing to check. The default application wiring is launchd-backed. `create_services` constructs `AutomationService` with `LaunchdSchedulerAdapter`, then injects it into the config service. The adapter shells out to `launchctl` for install, uninstall, and status checks [@app][@launchd]. Config reconciliation is macOS-specific until another scheduler adapter is wired. @@ -78,6 +90,8 @@ Manual update and scheduled update share the same planning logic. `plan_update` Scheduled update adds safety checks before running the package command. It skips if the install is not ready, if an update lock is already held, or if the local database shows active CodeAlmanac jobs [@updates]. After a successful scheduled package update, it runs two smoke checks: `codealmanac --version` and `codealmanac doctor --json` [@updates]. Tests cover the uv and pip plans, editable-install refusal, active-job skip, held-lock skip, smoke success, and smoke failure paths [@update_tests]. +The lock only guards the scheduled path. `UpdatesService.run` calls `run_scheduled(...)`, which acquires the lock, only when `request.scheduled` is set; a manual `codealmanac update` calls `run_plan(...)` directly and never touches `UpdateLockStore` [@updates]. So a manually invoked update can run at the same time as the launchd-triggered `update --scheduled` job, even though two scheduled invocations cannot overlap under a fresh lock. This asymmetry is unfixed; the lock protects background automation from itself, not manual invocations from automation. + The editable-install guard does not cover every PEP 610 direct-url install. The metadata reader records `source_url` from `direct_url.json`, but `update_method` only uses the `editable` flag before falling through to the installer-based uv or pip plan [@update_metadata][@updates]. A non-editable local path, VCS, or direct archive install with `INSTALLER` set to `uv` or `pip` can therefore still be upgraded through the normal package command. This shape matches the active agreement: scheduled auto-update is an explicit local automation task, not a sync or Garden side effect. The agreement also states that scheduled update should skip editable installs, skip active lifecycle jobs, use a global lock, support uv tool and pip installs, and run the same two smoke checks [@live_agreement]. diff --git a/almanac/architecture/setup/instruction-installation.md b/almanac/architecture/setup/instruction-installation.md index 24551336..eb98aae8 100644 --- a/almanac/architecture/setup/instruction-installation.md +++ b/almanac/architecture/setup/instruction-installation.md @@ -66,9 +66,23 @@ unnecessary (typos, mechanical edits), how the `almanac/` tree, page identity, and `topics.yaml` fit together, then concrete `codealmanac search` (text, `--mentions`, `--topic`) and `codealmanac show PAGE` (`--lead`, `--body`, `--links`, `--backlinks`, `--files`, `--topics`) usage matching the public CLI -surface [@guide-content] [@command-surface]. It also tells the agent that code -is authoritative over the wiki for current runtime behavior, and to report a -contradiction rather than propagate it into new code. +surface [@guide-content] [@command-surface]. It also covers `codealmanac +topics` and `topics show TOPIC` for browsing a wiki area by subject, +`codealmanac list` for reading across multiple registered repositories, and +`codealmanac health` and `codealmanac validate` for checking whether the wiki +itself looks wrong [@guide-content]. It states an explicit trust order for a +contradiction: current code outranks the Almanac, and the Almanac outranks +ordinary repository documentation [@guide-content]. + +Between the search/show usage and the topics/list/health coverage, the guide +also instructs the agent to cite CodeAlmanac in its own final answers: when +Almanac knowledge contributes to a final answer, cite the supporting page +inline, immediately after the claim, as a Markdown link to the committed page +using its repository-relative path [@guide-content]. This is a citation +obligation the installed agent owes the user in its own responses, distinct +from the in-page `sources:` frontmatter and citation-marker convention that +wiki maintenance operations use when writing pages (see +[Frontmatter and sources](../../reference/page-format/frontmatter-and-sources)). The second half is a boundary statement: during ordinary coding work the agent should treat the wiki as read-only, searching and reading it but not diff --git a/almanac/architecture/telemetry.md b/almanac/architecture/telemetry.md new file mode 100644 index 00000000..aae3eafa --- /dev/null +++ b/almanac/architecture/telemetry.md @@ -0,0 +1,83 @@ +--- +title: Telemetry +topics: [architecture, telemetry, config, local-state] +sources: + - id: live-agreement + type: file + path: docs/python-port-live-agreement.md + note: Current product boundary and telemetry exception. + - id: app-root + type: file + path: src/codealmanac/app.py + note: Composition root wiring for telemetry service, identity store, and sender. + - id: telemetry-service + type: file + path: src/codealmanac/services/telemetry/service.py + note: Telemetry capture policy, opt-out checks, and structural event construction. + - id: telemetry-models + type: file + path: src/codealmanac/services/telemetry/models.py + note: Allowlisted telemetry event names and properties. + - id: telemetry-store + type: file + path: src/codealmanac/services/telemetry/store.py + note: Local SQLite identity and once-only event claim storage. + - id: telemetry-sender + type: file + path: src/codealmanac/integrations/telemetry/sender.py + note: Detached PostHog sender and delivery-time privacy controls. + - id: cli-execution + type: file + path: src/codealmanac/cli/execution.py + note: CLI command and exception telemetry entrypoint. + - id: runs-service + type: file + path: src/codealmanac/services/runs/service.py + note: Lifecycle terminal event capture. + - id: run-worker + type: file + path: src/codealmanac/workflows/run_queue/worker.py + note: Worker-side exception capture for failed queued execution. + - id: telemetry-tests + type: file + path: tests/test_telemetry_service.py + note: Tests for identity, opt-out, allowlisted properties, once-only capture, and exception privacy. + - id: sender-tests + type: file + path: tests/test_telemetry_sender.py + note: Tests for detached sender behavior, PostHog settings, and bounded payloads. + - id: run-telemetry-tests + type: file + path: tests/test_run_telemetry.py + note: Tests for lifecycle telemetry privacy and failure categories. +--- + +# Telemetry + +Telemetry is CodeAlmanac's narrow remote product-signal layer. The product is still local-first: wiki source, source code, prompts, transcripts, repository identifiers, run identifiers, paths, command arguments, and exception messages stay on the machine, while telemetry may send only allowlisted command outcomes, lifecycle outcomes, and structural unhandled exception shapes [@live-agreement] [@telemetry-models]. + +The service boundary is split like the rest of the application. `TelemetryService` owns product policy and event construction; `TelemetryIdentityStore` owns the local SQLite identity and once-only event claims; `SubprocessTelemetrySender` owns PostHog delivery as an integration detail [@telemetry-service] [@telemetry-store] [@telemetry-sender]. The [Composition root](composition-root) wires those pieces into the app graph, so CLI commands, run services, and workers use the same service instead of importing PostHog or sender code directly [@app-root]. + +## Policy And Identity + +`telemetry.enabled` is the user config switch. `CODEALMANAC_NO_TELEMETRY`, `DO_NOT_TRACK`, and `CI` disable capture before config is consulted, and the service reloads config policy for each event unless it has intentionally frozen policy before local-state removal [@telemetry-service]. The config surface is documented in [Config keys](../reference/config-keys), and setup consent is fixed by [Telemetry permission is final setup step](../decisions/telemetry-permission-is-final-setup-step). + +The identity is a random UUID stored in local SQLite, not a TOML field. `TelemetryIdentityStore.get_or_create` stores one `telemetry_installation` row and returns an anonymous identity whose `distinct_id` is the UUID string [@telemetry-store]. Terminal lifecycle capture happens only on the first durable non-terminal-to-terminal transition; `claim_event` stores event keys in `telemetry_delivery` as an additional duplicate-delivery guard [@telemetry-store] [@telemetry-tests]. A transition completed while opted out is therefore never replayed if telemetry is later re-enabled. + +## Event Surface + +The allowlist lives in `TelemetryEvent.only_allow_event_properties`. The supported event names are `cli command completed`, `lifecycle run completed`, and `$exception`; each has a fixed property set plus common environment fields such as CLI version, identity kind, OS family, OS major version, architecture, Python major/minor version, and `$geoip_disable` [@telemetry-models] [@telemetry-service]. + +CLI execution captures command completion and unhandled exceptions through the telemetry service [@cli-execution]. `RunsService` captures the first lifecycle terminal transition through `capture_lifecycle`, using run kind, status, harness, model, duration bucket, and the controlled failure category persisted on failed run records [@runs-service] [@telemetry-service]. The typed lifecycle model validates the harness/model pair against the central controlled catalog and drops the event if the value is unknown or incompatible; arbitrary historical run-spec text can therefore remain locally readable without becoming outbound data. The terminal-telemetry helper treats both the supporting run-spec read and event shaping as best effort, so neither can make an already-committed transition appear to fail. The run queue worker also captures structural exceptions when worker execution fails before normal run completion can handle the failure [@run-worker]. + +Lifecycle failure categories are workflow facts, not telemetry guesses. The operation runner persists the explicit phase that failed, including harness readiness, provider execution, source preparation, indexing, wiki validation, and internal machinery; telemetry only projects that durable category [@runs-service] [@telemetry-service]. + +Exception capture never calls `str(error)` or sends an exception message. It keeps only the exception type, CodeAlmanac module/function/line frames, and a stable fingerprint derived from that structural data; shaping is fail-closed so a telemetry error cannot replace the product exception [@telemetry-service]. Tests assert that arbitrary private error text, source paths, run IDs, provider output, and unknown properties do not leave the process [@telemetry-tests] [@run-telemetry-tests]. + +## Delivery Boundary + +The default sender serializes the shaped event and starts a detached `python -m codealmanac.integrations.telemetry.sender` subprocess with stdout and stderr discarded [@telemetry-sender]. Delivery is best-effort: oversized payloads are dropped, sender exceptions do not change product behavior, and importing the parent sender does not import the PostHog SDK [@telemetry-service] [@telemetry-sender] [@sender-tests]. + +The delivery process uses PostHog's US ingestion host with sync delivery, a five-second timeout, GeoIP disabled, exception autocapture disabled, code-variable capture disabled, and a `before_send` hook that rebuilds the outbound property map from the exact keys validated on the typed event [@telemetry-sender]. This positive allowlist prevents future SDK-added context from crossing the boundary; if rebuilding ever fails, the hook drops the event because the SDK otherwise falls back to sending the unmodified payload. The event UUID is passed as PostHog's `uuid`, and the anonymous installation UUID is the distinct ID [@telemetry-sender] [@sender-tests]. + +This architecture keeps the remote exception explicit. New telemetry events should extend the typed model allowlist, add tests that prove no content or identifiers leak, and stay behind `TelemetryService` rather than adding sender calls from command, workflow, or service code. diff --git a/almanac/architecture/viewer/local-viewer.md b/almanac/architecture/viewer/local-viewer.md index 68ab5aec..3a74db70 100644 --- a/almanac/architecture/viewer/local-viewer.md +++ b/almanac/architecture/viewer/local-viewer.md @@ -26,6 +26,10 @@ sources: type: file path: src/codealmanac/services/viewer/service.py note: Viewer service entrypoints and projection flow. + - id: renderer + type: file + path: src/codealmanac/services/viewer/renderer.py + note: Markdown rendering and leading-title-heading removal. - id: scope type: file path: src/codealmanac/services/viewer/repository_scope.py @@ -60,13 +64,13 @@ sources: The local viewer is the read-only browser surface exposed by `codealmanac serve`. It projects repo wiki pages, topics, file evidence, registered wiki navigation, and lifecycle jobs from local state; it does not own a separate store or write path [@readme] [@viewer_service]. -The command accepts `--wiki`, `--host`, and `--port`, with parser defaults of `127.0.0.1:3927` when host and port are omitted [@parser]. Pages, links, topics, source references, and file references come from the index read model. Job lists and job detail come from the run ledger. The browser routes between those projections with hash routes and renders the current repository's wiki graph [@viewer_service] [@viewer_js]. +The command accepts `--wiki`, `--host`, `--port`, and `--no-open`, with parser defaults of `127.0.0.1:3927` when host and port are omitted [@parser]. It opens the viewer in the default browser unless `--no-open` is set. Pages, links, topics, source references, and file references come from the index read model. Job lists and job detail come from the [run ledger](../../concepts/run-ledger). The browser routes between those projections with hash routes and renders the current repository's wiki graph [@viewer_service] [@viewer_js]. For the public command contract, see [Public command surface](../../reference/cli/public-command-surface). For the run data shown in the jobs view, see [Run states and events](../../reference/runs/run-states-and-events). ## Entry Point -The serve dispatch imports `uvicorn` and the FastAPI server lazily, builds the server with the current working directory and optional wiki name, prints the viewer URL, and runs the server with warning-level logs [@dispatch]. `create_server_app` is the server composition root: it registers error handlers, API routes, and static routes without defining endpoint logic itself [@server_app]. +The serve dispatch imports `uvicorn` and the FastAPI server lazily, builds the server with the current working directory and optional wiki name, and prints the viewer URL [@dispatch]. Unless `--no-open` is set, a daemon thread waits for Uvicorn to bind successfully before opening that URL through Python's default-browser integration. The dispatch then runs the server with warning-level logs [@dispatch]. `create_server_app` is the server composition root: it registers error handlers, API routes, and static routes without defining endpoint logic itself [@server_app]. The API layer maps HTTP routes to `ViewerService` request objects. `/api/overview`, `/api/page/{slug}`, `/api/search`, `/api/file`, `/api/topic/{slug}`, `/api/jobs`, and `/api/jobs/{run_id}` all call the viewer service through the application object [@server_api]. When `codealmanac serve --wiki ` scopes the server, the API uses that repository name instead of per-request `wiki` parameters [@server_api]. @@ -82,6 +86,8 @@ The main entrypoints are `overview`, `page`, `search`, `file`, `topic`, `jobs`, Page detail combines index data with rendered Markdown. `page` loads the `PageView`, renders the body, and returns title, summary, topics, raw body, HTML, backlinks, outgoing links, file references, sources, and related pages [@viewer_service]. Related pages are derived from incoming and outgoing page links, with duplicates and self-links removed [@viewer_service]. +`MarkdownRenderer.render` also drops a leading `# ` heading from the rendered body when it matches the page's title, through `drop_leading_title_heading` [@renderer]. This is the body-side half of the single-heading-per-view shape; the breadcrumb change described below is the header-side half. + The projection module keeps conversion rules small and explicit. It maps repositories, topics, sources, and page summaries into viewer models [@projections]. Source display order follows citation order when the renderer reports citations; uncited sources sort after cited sources by source id [@projections]. File paths are shown relative to the repository's `almanac/` path when possible, with the old `pages/` prefix stripped if it appears in indexed data [@projections]. Tests make the reader contract concrete. The viewer renders Markdown links as viewer routes, escapes script tags, exposes code-formatted file references, reports backlinks, lists pages that mention file and folder references, and rejects file requests outside repo-relative reference space [@viewer_tests]. @@ -98,6 +104,8 @@ The static viewer script keeps local UI state for the overview and selected repo Routes cover home, page, topic, search, file, jobs, and job detail [@viewer_js]. Repository changes clear job polling, reload the overview for the selected wiki, refresh navigation, and reroute the current hash [@viewer_js]. Navigation is built from topics and a folder-style page tree, so nested wiki paths appear as browseable folders rather than a flat page list [@viewer_js]. +Each route reports its own breadcrumb trail instead of a single page-level title. The header renders that trail as `CodeAlmanac > ... > <current>`, with a page route contributing its folder path plus title and other routes (jobs, search, topics, files) contributing their own trail [@viewer_js]. This replaced an earlier header that repeated the page title as a second top-level heading; the current shape keeps exactly one page-title heading per view. + ## Boundaries The viewer is intentionally read-only. The architecture tests forbid mutating run request types such as `StartRunRequest`, `FinishRunRequest`, `QueueRunRequest`, and `CancelRunRequest` from the viewer service, viewer jobs projection, server app, and server API modules [@architecture_tests]. The same tests keep repository selection and projection logic outside `ViewerService`, so service methods stay focused on assembling viewer responses from existing read models [@architecture_tests]. diff --git a/almanac/architecture/wiki/health-and-validation.md b/almanac/architecture/wiki/health-and-validation.md index 8c80b9ad..b7a094ee 100644 --- a/almanac/architecture/wiki/health-and-validation.md +++ b/almanac/architecture/wiki/health-and-validation.md @@ -40,7 +40,7 @@ This matters because the committed `almanac/` tree is source. A lifecycle agent ## Service Boundary -`HealthService.check` selects a repository and returns the index service's health report [@health-service]. It is a read-side diagnostic. +`HealthService.check` selects a repository and returns the index service's health report [@health-service]. It is a read-side diagnostic, consistent with the read/write split described in [Service boundaries](../service-boundaries). `HealthService.validate` is stricter. It gathers source shape issues and runtime-state issues, then calls `index.ensure_fresh`. If index refresh fails, the validation result records a `page_routes` issue and stops before running index health queries [@health-service]. If refresh succeeds, validation adds issues derived from the health report [@health-service]. @@ -48,7 +48,7 @@ This matters because the committed `almanac/` tree is source. A lifecycle agent ## Indexed Health Report -The index-backed health report is assembled in `build_health_report`. It includes orphan pages, dead file references, broken page links, broken cross-wiki links, empty topics, empty pages, missing source citations, unused sources, and duplicate sources [@health-views]. +The index-backed health report is assembled in `build_health_report` against the derived index that [Index refresh and search](index-refresh-and-search) also queries. It includes orphan pages, dead file references, broken page links, broken cross-wiki links, empty topics, empty pages, missing source citations, unused sources, and duplicate sources [@health-views]. Most of those checks are graph or page checks. Orphan pages have no topic rows. Dead file references point at missing repo paths. Broken Markdown links target missing page slugs. Empty topics have no pages, and empty pages have no meaningful body text after headings are ignored [@health-graph]. diff --git a/almanac/architecture/wiki/index-refresh-and-search.md b/almanac/architecture/wiki/index-refresh-and-search.md index c062adca..a30d2772 100644 --- a/almanac/architecture/wiki/index-refresh-and-search.md +++ b/almanac/architecture/wiki/index-refresh-and-search.md @@ -30,6 +30,18 @@ sources: type: file path: src/codealmanac/services/search/service.py note: Search service selection and request handoff to the index. + - id: wiki-sections + type: file + path: src/codealmanac/services/wiki/sections.py + note: Heading-based Markdown section projection used to build search sections. + - id: index-query + type: file + path: src/codealmanac/services/index/query.py + note: Search query analysis that produces an OR-joined, prefix-matching FTS5 expression. + - id: slice-141-plan + type: file + path: docs/plans/slice-141-section-search.md + note: Implementation plan for section-level BM25 search, ranking, and collapse rules. - id: repo-readme type: file path: README.md @@ -40,7 +52,7 @@ sources: Index refresh and search are the read side of the local repo wiki. Markdown under `almanac/` is the source of truth, but query commands read from a derived SQLite index stored under the selected repository's local runtime directory [@repo-readme] [@index-service]. The index is refreshed implicitly before read operations, so users can edit Markdown and then run `search`, `show`, `topics`, or `health` without a separate rebuild step [@index-service]. -The derived index exists to make the wiki fast and queryable without making database files part of the committed wiki. It stores pages, topics, source entries, file references, page links, and an FTS5 table for text search [@index-schema] [@index-projection]. +The derived index exists to make the wiki fast and queryable without making database files part of the committed wiki. It stores pages, topics, source entries, file references, page links, Markdown-derived sections, and a section-level FTS5 table for text search [@index-schema] [@index-projection]. ## Refresh Owner @@ -54,16 +66,24 @@ Refresh is driven by a source signature. `load_index_sources(...)` loads page do If the stored signature matches, refresh reports no changed pages and leaves the index intact [@index-store]. If the signature differs, `replace_documents(...)` clears the derived tables and writes the current documents, topics, file references, page sources, page links, and FTS rows in one replacement pass [@index-store] [@index-projection]. A forced reindex skips the equality check and rebuilds from the current sources [@index-store]. +## Section Projection + +Search does not match whole pages directly. `project_sections(...)` parses each page's Markdown body with `markdown-it-py` and splits it into heading-based `WikiSection` rows: any text before the first heading becomes an introduction section under the page title, and each heading opens a new section that runs until the next heading at any level, carrying the ancestor heading path that produced it [@wiki-sections]. Section order is source order, and each section's `section_id` is a zero-padded ordinal, so rebuilding the same Markdown always produces the same section rows [@wiki-sections]. `replace_documents(...)` calls this projection for every page and writes one `page_sections` row and one `fts_sections` row per section, alongside the existing `pages` row [@index-projection]. + +This section layer is why search results can point at the specific heading that matched instead of only the page. It replaced an earlier whole-page `fts_pages` table with `page_sections` (heading path, ordinal, and section body) plus a section-scoped `fts_sections` FTS5 virtual table indexing page slug, page title, heading, and section body [@index-schema] [@slice-141-plan]. + ## Search Query Shape Search goes through `SearchService.search(...)`, which first selects the target repository for a read command and then calls `IndexService.search(...)` with query text, topics, mentions, and limit [@search-service]. The index service refreshes the repository before calling the store's search view [@index-service]. -When query text is present, `search_sql(...)` joins `pages` to `fts_pages`, builds a prefix `AND` FTS query from casefolded alphanumeric tokens, and orders results by FTS rank, newest `updated_at`, and slug [@search-views]. Topic filters use `EXISTS` checks against `page_topics`, with requested topic names normalized to kebab-case [@search-views]. Without query text, results are ordered by newest `updated_at` and slug [@search-views]. +Query analysis is deliberately recall-oriented rather than strict. `analyze_search_query(...)` casefolds the query, extracts Unicode word tokens, drops duplicates while keeping first-seen order, and joins the tokens with `OR` as quoted FTS5 prefix terms, so a query only needs one matching token to surface a section instead of requiring every token to match [@index-query]. This replaced an earlier strict `AND` query over whole pages, which rejected natural-language questions that used words the page didn't contain [@slice-141-plan]. + +When query text is present, `search_sql(...)` joins `pages` to `page_sections` and `fts_sections`, scores each matching section with weighted `bm25(...)` so title and heading matches outrank body-only matches, and keeps only the best-scoring section per page with a `ROW_NUMBER() OVER (PARTITION BY slug ...)` window before returning results [@search-views]. Final results are ordered by that section's BM25 rank, then newest `updated_at`, then slug [@search-views]. Each result carries the matched section's heading path and a bounded `snippet()` excerpt through `matched_heading` and `excerpt`, in addition to the existing page fields [@search-views]. Topic filters use `EXISTS` checks against `page_topics`, with requested topic names normalized to kebab-case [@search-views]. Without query text, `matched_heading` and `excerpt` are absent and results are ordered by newest `updated_at` and slug, the same as before section-level search [@search-views]. File mention search uses the indexed `file_refs` table. It supports exact file references, directory references that cover child files, and folder queries that match references under that folder [@search-views]. The details of path normalization and `GLOB` escaping live in [Path normalization and file refs](path-normalization-and-file-refs). ## Derived Store Boundary -The index schema is intentionally a read model. `pages` stores the route, title, summary, file path, content hash, mtime, and body; `fts_pages` stores searchable text; relationship tables store topics, sources, page links, cross-wiki links, and file references [@index-schema]. `replace_documents(...)` rebuilds those tables from loaded Markdown and `topics.yaml` rather than treating them as authored state [@index-projection]. +The index schema is intentionally a read model. `pages` stores the route, title, summary, file path, content hash, mtime, and body; `page_sections` and `fts_sections` store the section projection used for search; relationship tables store topics, sources, page links, cross-wiki links, and file references [@index-schema]. `replace_documents(...)` rebuilds those tables from loaded Markdown and `topics.yaml` rather than treating them as authored state [@index-projection]. That boundary is why read commands can refresh silently. The database can be dropped or rebuilt from the committed wiki source. For the persistence pattern behind these stores, see [SQLite store boundaries](../persistence/sqlite-store-boundaries). For the public commands that exercise this surface, see [CLI public command surface](../../reference/cli/public-command-surface). diff --git a/almanac/architecture/wiki/page-identity.md b/almanac/architecture/wiki/page-identity.md index 97214203..12db90bc 100644 --- a/almanac/architecture/wiki/page-identity.md +++ b/almanac/architecture/wiki/page-identity.md @@ -70,7 +70,7 @@ This check protects links and search results from ambiguity. A page slug should CodeAlmanac uses normal Markdown links for page links. The link parser ignores external URLs, anchors, absolute paths, query strings, backslash paths, paths with spaces, and hrefs that look like files with suffixes [@wiki-links]. It resolves relative links against the source page's route, with special handling for folder landing pages [@wiki-links]. -The parser tests show the intended behavior: relative links resolve against the source page route, while file-looking links such as `src/auth.py` are ignored as page links [@wiki-parsing-tests]. For example, from `architecture/indexing`, `wiki-tree` resolves to `architecture/wiki-tree`, and `../decisions/local-only-python-product` resolves to `decisions/local-only-python-product` by the same relative-route rule [@wiki-links]. For the exact page-link rules, see [Links and routes](../../reference/page-format/links-and-routes). +The parser tests show the intended behavior: relative links resolve against the source page route, while file-looking links such as `src/auth.py` are ignored as page links [@wiki-parsing-tests]. For example, from `architecture/indexing`, `wiki-tree` resolves to `architecture/wiki-tree`, and `../decisions/local-first-python` resolves to `decisions/local-first-python` by the same relative-route rule [@wiki-links] [@wiki-parsing-tests]. For the exact page-link rules, see [Links and routes](../../reference/page-format/links-and-routes). ## What Future Changes Must Preserve diff --git a/almanac/concepts/README.md b/almanac/concepts/README.md index 14c5bf20..352c3d7a 100644 --- a/almanac/concepts/README.md +++ b/almanac/concepts/README.md @@ -50,7 +50,9 @@ state instead [@local-repo-wiki]. topics, links, file references, sources, backlinks, and health checks [@page-graph]. Why concept, guide, decision, and reference vocabulary exists today without being a validated frontmatter field is a product decision; see -[No page-kind field](../decisions/no-page-kind-field). +[No page-kind field](../decisions/no-page-kind-field). For the implementation +pages behind that derived model, continue to the +[Wiki architecture](../architecture/wiki) hub. ## Lifecycle And Inputs diff --git a/almanac/concepts/page-graph.md b/almanac/concepts/page-graph.md index 75a686ef..70514e1d 100644 --- a/almanac/concepts/page-graph.md +++ b/almanac/concepts/page-graph.md @@ -36,7 +36,7 @@ This concept matters because CodeAlmanac is more than a folder of Markdown files ## Parts Of The Graph -The index has tables for `pages`, `topics`, `page_topics`, `topic_parents`, `file_refs`, `page_sources`, `page_links`, `cross_wiki_links`, and `fts_pages` [@index_schema]. The current document loader fills local `page_links` from Markdown links and leaves `cross_wiki_links` empty [@wiki_documents]. These tables separate authored material from derived lookup state. The Markdown file remains the source; the index is the read model. +The index has tables for `pages`, `topics`, `page_topics`, `topic_parents`, `file_refs`, `page_sources`, `page_links`, `cross_wiki_links`, and a heading-based `page_sections`/`fts_sections` pair that backs search [@index_schema]. The current document loader fills local `page_links` from Markdown links and leaves `cross_wiki_links` empty [@wiki_documents]. These tables separate authored material from derived lookup state. The Markdown file remains the source; the index is the read model. See [Index refresh and search](../architecture/wiki/index-refresh-and-search) for how the section tables are built and queried. Topics group pages across folders. Topic parent rows form a directed topic structure, while page-topic rows attach individual pages to the subjects they explain [@index_schema]. This is the base for [Topics DAG](../architecture/wiki/topics-dag) and [topics.yaml](../reference/topics-yaml). diff --git a/almanac/concepts/run-ledger.md b/almanac/concepts/run-ledger.md index 93fd9b6f..88313bf9 100644 --- a/almanac/concepts/run-ledger.md +++ b/almanac/concepts/run-ledger.md @@ -22,17 +22,21 @@ sources: type: file path: src/codealmanac/workflows/run_queue/control.py note: Product cancellation use case coordinating durable state and process control. + - id: jobs_dispatch + type: file + path: src/codealmanac/cli/dispatch/jobs.py + note: Jobs command dispatch, including attach interruption handling. --- # Run Ledger The run ledger is CodeAlmanac's local record of lifecycle work. It joins run records, queued run specs, run events, worker locks, transcript references, and job inspection into one durable model. The user sees it through `codealmanac jobs`, while internal services call it `runs` because it records the lifecycle state of build, ingest, and garden operations [@jobs_parser][@run_models]. -A run is not just a log. It is the state object that says which repository the work belongs to, what kind of lifecycle operation it is, whether it is queued, running, done, failed, or cancelled, and what events have happened so far [@run_models]. The ledger lets agents and humans inspect background work without relying on hidden process state. +A run is not just a log. It is the state object that says which repository the work belongs to, what kind of [lifecycle operation](lifecycle-operation) it is, whether it is queued, running, done, failed, or cancelled, and what events have happened so far [@run_models]. The ledger lets agents and humans inspect background work without relying on hidden process state. ## What It Records -The core record is `RunRecord`. It stores a run id, repository id, run kind, status, title, timestamps, optional summary, optional error, page changes, and an optional harness transcript reference [@run_models]. A running record also stores its verified execution reference and optional cancellation-request time. Run kinds are limited to `build`, `ingest`, and `garden`; statuses move through `queued`, `running`, and terminal states such as `done`, `failed`, or `cancelled` [@run_models]. +The core record is `RunRecord`. It stores a run id, repository id, run kind, status, title, timestamps, optional summary, optional error, page changes, and an optional harness transcript reference [@run_models]. Failed records also persist a controlled workflow-phase category such as harness readiness, provider execution, source preparation, indexing, wiki validation, or internal error; legacy failed records without one remain readable. A running record stores its verified execution reference and optional cancellation-request time. Run kinds are limited to `build`, `ingest`, and `garden`; statuses move through `queued`, `running`, and terminal states such as `done`, `failed`, or `cancelled` [@run_models]. Queued work has an optional `RunSpec`. The spec stores the operation kind, harness, model, inputs, guidance, title, and auto-commit policy for work that a local worker can later pick up [@run_models]. That makes queue membership explicit: a queued run with a stored spec is work the worker can drain. @@ -40,9 +44,9 @@ Events are stored separately from the run record. Each `RunLogEvent` has a seque ## How It Works -`RunsService` is the service boundary around the ledger. It starts runs, queues specs, lists runs, reads specs, finds the next queued run, records events, atomically claims runs with execution identity, records cancellation intent, and finishes terminal transitions [@run_service]. Process termination is coordinated by the run-queue workflow rather than the persistence service [@run_control]. Repository-name filtering prevents a job id from another registered repository being exposed through the wrong wiki selection [@run_service]. +`RunsService` is the service boundary around the ledger. It starts runs, queues specs, lists runs, reads specs, finds the next queued run, records events, atomically claims runs with execution identity, records cancellation intent, and finishes terminal transitions [@run_service]. Internal finish and cancellation results expose whether durable state changed, so side effects such as telemetry run only for the first terminal transition. Process termination is coordinated by the run-queue workflow rather than the persistence service [@run_control]. Repository-name filtering prevents a job id from another registered repository being exposed through the wrong wiki selection [@run_service]. -`RunStore` owns persistence. It writes run records to the local database, stores the serialized spec beside the run record, appends status events, and delegates event sequencing to the event store [@run_store]. The same store exposes `attach`, which returns the current run record, all events, and a `terminal` flag for attach-style streaming [@run_store]. +`RunStore` owns persistence. It writes run records to the local database, stores the serialized spec beside the run record, appends status events, and delegates event sequencing to the event store [@run_store]. The same store exposes `attach`, which returns the current run record, all events, and a `terminal` flag for attach-style streaming [@run_store]. Attaching is a read-only view onto the ledger: interrupting the foreground `jobs attach` command with `Ctrl-C` detaches the CLI without mutating the run [@jobs_dispatch], so the ledger still needs an explicit `jobs cancel` to record cancellation intent [@run_control]. See [Run states and events](../reference/runs/run-states-and-events) for the exact attach and interruption contract. The public command name is `jobs`, not `runs`. The parser exposes `jobs`, `jobs show`, `jobs logs`, `jobs attach`, and `jobs cancel`, with JSON output available on the read/control commands [@jobs_parser]. That naming keeps the user surface practical while leaving the internal model precise. diff --git a/almanac/decisions/README.md b/almanac/decisions/README.md index a6dec52c..5b93a319 100644 --- a/almanac/decisions/README.md +++ b/almanac/decisions/README.md @@ -34,6 +34,14 @@ sources: type: wiki path: decisions/controlled-model-catalog note: Decision that keeps supported harness models in a controlled catalog. + - id: repo-affiliation + type: wiki + path: decisions/repository-affiliation-belongs-in-repository-service + note: Proposed decision that gives the repository service ownership of checkout-to-repository affiliation. + - id: telemetry-setup + type: wiki + path: decisions/telemetry-permission-is-final-setup-step + note: Decision for setup ordering and telemetry consent behavior. --- # Decisions @@ -81,3 +89,20 @@ does not stage, split, or commit wiki diffs itself [@auto-commit]. [Controlled model catalog](controlled-model-catalog) decides that supported runner models are owned by CodeAlmanac config and tests, not by provider discovery or provider defaults [@model-catalog]. + +## Setup Consent + +[Telemetry permission is final setup step](telemetry-permission-is-final-setup-step) +records the setup contract for the narrow telemetry exception: telemetry +permission comes after runner, model, instruction, maintenance, update, and +change-handling choices, with an explicit `telemetry.enabled` config setting +[@telemetry-setup]. + +## Repository Scope + +[Repository affiliation belongs in repository service](repository-affiliation-belongs-in-repository-service) +is a proposed decision, not yet implemented. It records the intended +ownership boundary for mapping a transcript's working directory back to a +canonical registered repository, so that future worktree or nested-checkout +support lands as a repository-service method instead of Conductor-specific +sync logic [@repo-affiliation]. diff --git a/almanac/decisions/auto-commit-is-prompt-policy.md b/almanac/decisions/auto-commit-is-prompt-policy.md index 82e377f0..c873c0ab 100644 --- a/almanac/decisions/auto-commit-is-prompt-policy.md +++ b/almanac/decisions/auto-commit-is-prompt-policy.md @@ -42,6 +42,10 @@ CodeAlmanac treats auto-commit as permission given to the lifecycle agent, not a This decision keeps authorship and judgment together. Lifecycle agents decide what wiki source changed and whether it is worth committing within the allowed policy. Python records the harness run, refreshes the index, and validates the wiki after harness success; it does not become a separate commit orchestration pipeline [@operation_service]. +## Status + +Accepted. `operation_commit_policy` and the architecture tests forbidding a Python-side committer are both in place [@commit_policy][@architecture_tests]. + ## Context The active Python agreement states the rule directly: intelligence lives in prompts, and auto-commit is prompt policy rather than CodeAlmanac staging or smart Git orchestration [@live_agreement]. It also records the default as `auto_commit = true`, with `setup --no-auto-commit` and `config set auto_commit false` changing only the prompt permission [@live_agreement]. @@ -62,4 +66,4 @@ Python does not own a committer abstraction. Architecture tests reject files nam The upside is a small lifecycle core. CodeAlmanac can render policy, run the harness, record events, refresh the index, validate the wiki, and mark the run terminal without splitting writing and committing into separate engines [@operation_service]. The cost is that commit quality depends on the agent following the prompt, so the policy must stay clear. -This decision is part of the same product stance as [No propose/apply or dry-run](no-propose-apply-or-dry-run): when judgment belongs to the agent, the product gives the agent real source material and clear constraints instead of building a second state machine around it. +This decision is part of the same product stance as [No propose/apply or dry-run](no-propose-apply-or-dry-run): when judgment belongs to the agent, the product gives the agent real [source material](../concepts/source-material) and clear constraints instead of building a second state machine around it. diff --git a/almanac/decisions/controlled-model-catalog.md b/almanac/decisions/controlled-model-catalog.md index fa682dfb..5482f9bd 100644 --- a/almanac/decisions/controlled-model-catalog.md +++ b/almanac/decisions/controlled-model-catalog.md @@ -24,7 +24,7 @@ sources: CodeAlmanac owns a controlled catalog of AI runner models. It does not discover arbitrary provider models during setup, accept provider defaults as product truth, or let harness adapters decide which models are supported [@config-plan]. The selected runner and model are stored in config as `harness.default` and `harness.model`, then passed toward lifecycle work as explicit settings [@config-models]. -The decision keeps model choice small, reviewable, and stable. Provider adapters can execute a chosen model, but the product list belongs to config and setup. This constrains future work in [Provider adapters](../architecture/agent-runs/provider-adapters), [Automation and update](../architecture/setup/automation-and-update), and [Config keys](../reference/config-keys). +The decision keeps model choice small, reviewable, and stable. The harness adapter can execute a chosen model, but the product list belongs to config and setup. This constrains future work in the [Yoke harness boundary](../architecture/agent-runs/provider-adapters), [Automation and update](../architecture/setup/automation-and-update), and [Config keys](../reference/config-keys). ## Status diff --git a/almanac/decisions/local-only-python-product.md b/almanac/decisions/local-only-python-product.md index cff45450..4822333a 100644 --- a/almanac/decisions/local-only-python-product.md +++ b/almanac/decisions/local-only-python-product.md @@ -1,6 +1,6 @@ --- title: Local-Only Python Product -topics: [decisions, product] +topics: [decisions, product, telemetry] sources: - id: live_agreement type: file @@ -18,10 +18,14 @@ sources: # Local-Only Python Product -CodeAlmanac's Python v1 is a local-only product. It is a Python CLI named `codealmanac` that writes a repo-local Markdown wiki, stores derived and operational state under `~/.codealmanac/`, and uses local agent harnesses for lifecycle work [@live_agreement][@readme]. Hosted login, cloud capture, upload, SDK, and MCP surfaces are outside this rewrite [@live_agreement]. +CodeAlmanac's Python v1 is a local-first product. It is a Python CLI named `codealmanac` that writes a repo-local Markdown wiki, stores derived and operational state under `~/.codealmanac/`, and uses local agent harnesses for lifecycle work [@live_agreement][@readme]. Hosted login, content upload, SDK, and MCP surfaces remain outside this rewrite. Anonymous opt-out product telemetry is the one narrow remote exception [@live_agreement]. This decision is the reset that makes the current codebase coherent. Earlier branches moved toward a hosted or cloud product, but the active agreement treats that direction as reference material rather than product truth [@live_agreement]. Future work should extend the local product described here, not reintroduce hosted workflow language casually. +## Status + +Accepted. The public README and setup flow describe only the local product surface, with anonymous telemetry as the one narrow remote exception [@readme][@live_agreement]. + ## Context The product needed a clear center. The live agreement records that later `dev` and `main` branches moved ahead on a hosted/cloud direction that the rewrite explicitly does not follow, and that this local-only shape is the architectural reset the current codebase should match [@live_agreement]. The same agreement says the Python rewrite targets new users and should not preserve legacy aliases, old roots, old page layouts, or hosted assumptions [@live_agreement]. @@ -32,12 +36,12 @@ The public README reflects that reset. Setup installs local instructions and aut CodeAlmanac Python v1 is built as a local Python CLI. The product name and public command are `codealmanac`, the package targets Python 3.12+, and the main read/write workflows operate on a local repository wiki [@readme]. -The product does not build hosted shipping, hosted CLI, login/connect/upload flows, an SDK, MCP integration, or cloud capture in this rewrite [@live_agreement]. If those surfaces become real again, they require a new explicit decision because they change the product boundary. +The product does not build hosted shipping, hosted CLI, login/connect/content-upload flows, an SDK, or MCP integration in this rewrite [@live_agreement]. The [Telemetry](../architecture/telemetry) exception sends only allowlisted command/lifecycle outcomes and sanitized unhandled exception shapes under a random installation UUID. Wiki content, source, prompts, transcripts, paths, and identifiers remain local. Any broader remote surface requires a new explicit decision [@live_agreement]. ## Consequences The local product makes [Local repo wiki](../concepts/local-repo-wiki) the central model. Repository registration, runs, run events, sync state, and worker locks belong in the local database under `~/.codealmanac/`, while the committed wiki source stays in the repo [@live_agreement][@readme]. -The decision also narrows public language. Docs and commands should not mention hosted dashboards, cloud upload, `capture`, legacy `almanac` or `alm` aliases, or Node-era installation paths as active product surfaces [@live_agreement]. This keeps [Local state](../architecture/repositories/local-state) and [Only almanac root](only-almanac-root) from carrying compatibility branches for retired product shapes. +The decision also narrows public language. Docs and commands should not present hosted dashboards, content upload, a public `capture` command, legacy `almanac` or `alm` aliases, or Node-era installation paths as active product surfaces [@live_agreement]. Product documentation must still disclose the telemetry exception and its opt-out. This keeps [Local state](../architecture/repositories/local-state) and [Only almanac root](only-almanac-root) from carrying compatibility branches for retired product shapes. -The cost is that remote collaboration, hosted capture, and cloud account flows are intentionally deferred. The benefit is a smaller, testable product whose state boundaries are visible on disk and in the local database. +The cost is that remote collaboration, hosted content, and cloud account flows are intentionally deferred. The benefit is a smaller, testable product whose content boundaries are visible on disk and whose remote product signals are explicit, allowlisted, and optional. diff --git a/almanac/decisions/markdown-links-and-sources.md b/almanac/decisions/markdown-links-and-sources.md index 81f15284..9bc06293 100644 --- a/almanac/decisions/markdown-links-and-sources.md +++ b/almanac/decisions/markdown-links-and-sources.md @@ -26,6 +26,10 @@ CodeAlmanac uses normal Markdown links for page links and structured `sources:` This decision separates navigation from evidence. A page link points a reader to another wiki page. A `sources:` entry tells the index and future maintainers which file, web page, commit, PR, issue, conversation, wiki page, or manual supports a claim [@frontmatter]. File evidence belongs in `sources:` with `type: file`, not in inline page links [@kernel]. +## Status + +Accepted. The link extractor and frontmatter parser both implement this contract, and no double-bracket link or legacy file-list parsing path remains in the wiki writer [@links][@frontmatter]. + ## Context The Python reset replaced older wiki syntax with a simpler authored format. The base writer prompt tells agents to use Markdown links such as `[Viewer](../viewer)`, link only to existing or newly created pages, cite non-obvious claims with inline source markers, and store file/folder evidence in `sources:` [@kernel]. @@ -34,9 +38,9 @@ The sources canonical plan made structured `sources:` the only authored evidence ## Decision -Page-to-page navigation is authored as normal Markdown. The link extractor parses CommonMark inline links and resolves relative, extensionless page paths into page slugs while ignoring absolute URLs, query links, slash-rooted paths, file-suffixed paths, and paths with spaces [@links]. +Page-to-page navigation is authored as normal Markdown links. The link extractor parses CommonMark inline links and resolves relative, extensionless page paths into page slugs, ignoring hrefs that are not page links, such as absolute URLs or file-suffixed paths [@links]. The exact ignored-href list and route-resolution rules are a lookup contract, not part of this decision; see [Links and routes](../reference/page-format/links-and-routes). -Evidence is authored in `sources:`. The frontmatter parser accepts `title`, `summary`, `topics`, and `sources`, and each source is parsed into a typed source with a source id, type, target, title, retrieval date, and note when present [@frontmatter]. Supported source types include file, web, commit, PR, issue, conversation, wiki, and manual [@frontmatter]. +Evidence is authored in `sources:` instead of a legacy frontmatter file list or inline links. Each entry is parsed into a typed source with a source id, type, target, and optional title, retrieval date, and note [@frontmatter]. The exact supported source types and target fields are documented in [Frontmatter and sources](../reference/page-format/frontmatter-and-sources), which this decision does not duplicate. ## Consequences diff --git a/almanac/decisions/no-propose-apply-or-dry-run.md b/almanac/decisions/no-propose-apply-or-dry-run.md index 85a91e1e..7f92dc46 100644 --- a/almanac/decisions/no-propose-apply-or-dry-run.md +++ b/almanac/decisions/no-propose-apply-or-dry-run.md @@ -30,10 +30,14 @@ sources: # No Propose/Apply Or Dry-Run -CodeAlmanac does not wrap wiki-writing agents in proposal files, apply steps, dry-run rehearsals, or approve/revise/reject state machines. When an operation needs judgment, the product gives an agent concrete source material, [prompts and manuals](../architecture/runtime-resources/prompts-and-manuals), and constraints, then expects the agent to write or no-op directly [@manual][@live_agreement]. +CodeAlmanac does not wrap wiki-writing agents in proposal files, apply steps, dry-run rehearsals, or approve/revise/reject state machines. When an operation needs judgment, the product gives an agent concrete [source material](../concepts/source-material), [prompts and manuals](../architecture/runtime-resources/prompts-and-manuals), and constraints, then expects the agent to write or no-op directly [@manual][@live_agreement]. The decision is structural. CodeAlmanac owns deterministic mechanics such as repository selection, source loading, harness execution, run logging, prompt rendering, indexing, and validation. The agent owns editorial judgment: what matters, what page should change, what links and topics make sense, and whether the best result is no change [@ingest_prompt][@garden_prompt]. +## Status + +Accepted. The ingest and garden prompts are open algorithms with no proposal, apply, or dry-run schema between writer and reviewer [@ingest_prompt][@garden_prompt]. + ## Context The manual names this as a repo-specific rule: intelligence lives in prompts, not pipelines. It forbids propose/review/apply state machines, orchestration JSON schemas between writer and reviewer, and `--dry-run` rehearsals; the writer owns outcomes [@manual]. @@ -42,7 +46,7 @@ The live agreement records the same reset for the Python product. It says the ha ## Decision -Lifecycle operation prompts are open algorithms, not schemas for intermediate artifacts. The ingest prompt tells the agent to understand bounded source material, inspect the current `almanac/` tree, verify important claims, prefer updating existing pages, create pages only for durable anchors, update topics and Markdown links, validate, and no-op when the source does not improve durable project knowledge [@ingest_prompt]. +[Lifecycle operation](../concepts/lifecycle-operation) prompts are open algorithms, not schemas for intermediate artifacts. The ingest prompt tells the agent to understand bounded source material, inspect the current `almanac/` tree, verify important claims, prefer updating existing pages, create pages only for durable anchors, update topics and Markdown links, validate, and no-op when the source does not improve durable project knowledge [@ingest_prompt]. The Garden prompt uses the same direct-editing model at graph scale. It asks the agent to inspect pages, topics, links, hubs, referenced files, sources, and health issues; find graph problems; merge, split, revise, or no-op as needed; and validate the result [@garden_prompt]. It allows helper agents for bounded audits, but the main agent owns final synthesis, page boundaries, topics, links, hubs, and prose [@garden_prompt]. diff --git a/almanac/decisions/only-almanac-root.md b/almanac/decisions/only-almanac-root.md index b40b4047..80c6847d 100644 --- a/almanac/decisions/only-almanac-root.md +++ b/almanac/decisions/only-almanac-root.md @@ -22,6 +22,10 @@ CodeAlmanac uses `almanac/` as the only repo wiki root. The committed wiki sourc This decision makes page identity and repository detection simple. A repository counts as initialized when the default `almanac/` directory contains `topics.yaml` and `README.md`, and the root-setting helper rejects any non-default root [@roots]. +## Status + +Accepted. `require_default_almanac_root` and `initialized_repository_at` enforce `almanac/` as the only root, and public-contract tests reject alternate roots [@roots][@public_contract]. + ## Context Earlier product shapes had old roots and compatibility paths. The current Python agreement says the happy path is the product path and explicitly drops old wiki roots, old page layouts, and root hopping [@live_agreement]. The public contract tests enforce this by rejecting `docs/almanac` and `.almanac` as valid roots and by checking that `init --root` is not accepted [@public_contract]. diff --git a/almanac/decisions/telemetry-permission-is-final-setup-step.md b/almanac/decisions/telemetry-permission-is-final-setup-step.md new file mode 100644 index 00000000..44d1c76b --- /dev/null +++ b/almanac/decisions/telemetry-permission-is-final-setup-step.md @@ -0,0 +1,69 @@ +--- +title: Telemetry Permission Is Final Setup Step +topics: [decisions, setup, config, product, telemetry] +sources: + - id: telemetry-plan-transcript + type: conversation + path: /Users/divitsheth/.codex/sessions/2026/07/15/rollout-2026-07-15T15-13-22-019f67d7-7966-7571-b190-551f5db09c4a.jsonl + note: Conversation that settled the setup order and telemetry permission requirements. + - id: setup-tui + type: file + path: src/codealmanac/cli/dispatch/setup_tui.py + note: Current interactive setup ordering and default-selection behavior. + - id: setup-selections + type: file + path: src/codealmanac/cli/dispatch/setup_wizard/models.py + note: Current setup selection model. + - id: config-models + type: file + path: src/codealmanac/services/config/models.py + note: Current supported config keys and user config model. + - id: telemetry-service + type: file + path: src/codealmanac/services/telemetry/service.py + note: Runtime telemetry opt-out policy, event construction, and exception redaction. + - id: telemetry-store + type: file + path: src/codealmanac/services/telemetry/store.py + note: Local SQLite installation UUID and once-only event claim storage. + - id: telemetry-sender + type: file + path: src/codealmanac/integrations/telemetry/sender.py + note: Detached PostHog sender and delivery privacy settings. +--- + +# Telemetry Permission Is Final Setup Step + +Telemetry permission is the final setup decision. The user first chooses the runner, model, instruction installation, wiki maintenance, product updates, and change-handling policy; the seventh screen then asks about anonymous usage and error telemetry [@telemetry-plan-transcript] [@setup-tui] [@setup-selections] [@config-models]. + +## Status + +Implemented. `telemetry.enabled` is part of user config and the seven-step setup flow; `--no-telemetry` is the explicit non-interactive opt-out [@config-models] [@setup-tui]. + +## Context + +Setup already asks several consent-like questions: where to install agent instructions, which local harness and model should run lifecycle jobs, whether wiki maintenance and product updates should be automated, and whether agents may commit wiki changes [@setup-tui]. The telemetry prompt is different because it asks permission to send anonymous usage information outside the machine. It therefore belongs after the user understands the local automation choices, not before them [@telemetry-plan-transcript]. + +Telemetry is separate from product content. It helps prioritize improvements and identify broken flows while excluding code, paths, prompts, transcripts, command arguments, queries, repository/run identifiers, locals, and code variables. Real unhandled exceptions may include only a bounded redacted message and sanitized CodeAlmanac stack shape [@telemetry-plan-transcript]. + +## Decision + +The intended interactive setup order is: + +1. AI provider. +2. Provider model. +3. Add instructions to `AGENTS.md` or `CLAUDE.md`. +4. Wiki maintenance. +5. Product updates. +6. Change handling. +7. Telemetry permission. + +The telemetry screen should default to "Yes" and mark that option Recommended, while keeping a visible and functional "No thanks" option [@telemetry-plan-transcript]. The screen copy should explain that sharing anonymous usage helps focus improvements and fix broken experiences faster. Code, paths, prompts, transcripts, and raw error text are never collected; crash reports contain only the bounded, locally sanitized exception shape above [@telemetry-plan-transcript]. + +Non-interactive setup follows the same policy. `setup --yes` accepts the saved/default Yes, while `setup --no-telemetry` persists No. There is no separate first-run notice mechanism [@telemetry-plan-transcript]. + +## Consequences + +Telemetry state belongs in user config as `telemetry.enabled`, and the public config surface lets users later run `codealmanac config set telemetry.enabled false` [@config-models]. The setup selection model carries `telemetry_enabled` beside targets, harness, model, update, commit, sync, and Garden settings, so telemetry follows the same shaped setup path as other persisted choices [@setup-selections]. + +The runtime uses a stable random installation UUID in local SQLite, a typed telemetry service, and a detached PostHog sender [@telemetry-store] [@telemetry-service] [@telemetry-sender]. GeoIP and code-variable capture are disabled [@telemetry-sender]. For the event and delivery boundary, see [Telemetry](../architecture/telemetry). diff --git a/almanac/guides/README.md b/almanac/guides/README.md index 48e16959..db704e09 100644 --- a/almanac/guides/README.md +++ b/almanac/guides/README.md @@ -42,6 +42,10 @@ sources: type: wiki path: guides/demo-codealmanac-in-launch-video note: Procedure for demonstrating CodeAlmanac in a launch video. + - id: release + type: wiki + path: guides/release-package + note: Procedure for publishing the codealmanac Python package to PyPI. --- # Guides @@ -90,3 +94,9 @@ before adding behavior [@refactoring]. product-demonstration guide for showing the local viewer and terminal query flow in a launch video, including the broader product-story and comparison framing behind that demo [@launch-demo]. + +## Ship A Release + +[Release package](release-package) is the checklist for publishing the +`codealmanac` package to PyPI: build gates, installed-artifact smoke checks, +publishing, and verifying the public install path [@release]. diff --git a/almanac/guides/add-a-harness-provider-adapter.md b/almanac/guides/add-a-harness-provider-adapter.md index 62596235..2cf103d7 100644 --- a/almanac/guides/add-a-harness-provider-adapter.md +++ b/almanac/guides/add-a-harness-provider-adapter.md @@ -28,7 +28,7 @@ Use this guide when CodeAlmanac needs to add a new local agent runner (a new `HarnessKind`, such as a future harness alongside Codex and Claude) or change which Yoke surface an existing runner uses. The guide has two parts because the work usually spans two repos: provider or surface support belongs in Yoke, and -the CodeAlmanac-side product choice — the `HarnessKind`, controlled models, and +the CodeAlmanac-side product choice — the `HarnessKind`, [controlled models](../decisions/controlled-model-catalog), and `YokeHarnessAdapter` registration — belongs here. ## Add Provider Support To Yoke First diff --git a/almanac/guides/debug-a-failed-lifecycle-run.md b/almanac/guides/debug-a-failed-lifecycle-run.md index 4fd3507e..120f6da0 100644 --- a/almanac/guides/debug-a-failed-lifecycle-run.md +++ b/almanac/guides/debug-a-failed-lifecycle-run.md @@ -20,18 +20,27 @@ sources: - id: yoke-results type: file path: src/codealmanac/integrations/harnesses/yoke/results.py + - id: jobs-dispatch + type: file + path: src/codealmanac/cli/dispatch/jobs.py + note: Jobs command dispatch, including attach interruption handling. --- # Debug A Failed Lifecycle Run Use this guide when build, ingest, or garden leaves a failed run. Start with `codealmanac jobs`, then inspect `jobs show <run-id>` and -`jobs logs <run-id>`. The durable run store keeps the record and normalized -event stream after the original process exits [@repo-readme] [@run-store]. +`jobs logs <run-id>`. The durable [run ledger](../concepts/run-ledger) keeps +the record and normalized event stream after the original process exits +[@repo-readme] [@run-store]. If the run is active, `codealmanac jobs attach <run-id>` follows the same event record until it becomes terminal. The readable log is the primary debugging surface; JSON output exposes the corresponding structured harness fields. +Pressing `Ctrl-C` while attached only detaches the foreground command without +mutating the run; cancel it explicitly with `codealmanac jobs cancel <run-id>` +if the run should stop [@jobs-dispatch]. See [Run states and events](../reference/runs/run-states-and-events) +for the full attach, detach, and cancellation contract. ## Identify The Boundary diff --git a/almanac/guides/demo-codealmanac-in-launch-video.md b/almanac/guides/demo-codealmanac-in-launch-video.md index b6e56235..5b5ba32c 100644 --- a/almanac/guides/demo-codealmanac-in-launch-video.md +++ b/almanac/guides/demo-codealmanac-in-launch-video.md @@ -14,6 +14,14 @@ sources: type: web url: https://github.com/bcurts/agentchattr note: Local multi-agent chat comparison point. + - id: setup-options + type: file + path: src/codealmanac/cli/dispatch/setup_wizard/options.py + note: Interactive setup telemetry option labels and explanatory copy. + - id: setup-parser + type: file + path: src/codealmanac/cli/parser/setup.py + note: Non-interactive setup telemetry opt-out flag. --- # Demo CodeAlmanac In A Launch Video @@ -26,6 +34,12 @@ Use a real repository wiki and keep the sequence short [@public-readme]: 3. Run `codealmanac search` and `codealmanac show` to retrieve the same durable knowledge as an agent would before coding. +If the video includes onboarding, show the final telemetry choice truthfully: +Yes is recommended to improve the CLI, No remains visible, and the copy excludes +code, paths, arguments, prompts, and transcripts. For a recording that should +not generate product signals, run `codealmanac setup --yes --no-telemetry` +[@setup-options] [@setup-parser]. + The viewer establishes the noun; the terminal demonstrates the verb. Avoid a folder tour or implementation explanation before the query. The payoff is that future coding agents can retrieve maintained project understanding rather than diff --git a/almanac/guides/release-package.md b/almanac/guides/release-package.md index 9928378d..acfe1aae 100644 --- a/almanac/guides/release-package.md +++ b/almanac/guides/release-package.md @@ -1,6 +1,6 @@ --- title: Release Package -topics: [guides, release, product] +topics: [guides, product] sources: - id: release-doc type: file @@ -9,7 +9,7 @@ sources: - id: pyproject type: file path: pyproject.toml - note: Package name, version, Python requirement, dependencies, script entrypoint, and package data. + note: Package name, version, Python requirement, dependencies, script entrypoints, and package data. - id: readme type: file path: README.md @@ -30,13 +30,13 @@ sources: # Release Package -Use this guide when publishing the `codealmanac` Python package to PyPI. A release publishes the local CLI package and its packaged resources; it does not publish a hosted service, npm package, SDK, MCP package, or legacy command alias, matching the [Local-only Python product](../decisions/local-only-python-product) decision [@release-doc] [@public-contract-tests]. The release is not done when PyPI accepts files. It is done when clean installed artifacts and the public install path can run the `codealmanac` command outside the repo developer environment [@release-doc] [@readme]. +Use this guide when publishing the `codealmanac` Python package to PyPI. A release publishes the local CLI package and its packaged resources; it does not publish a hosted service, npm package, SDK, MCP package, or undeclared legacy command alias, matching the [Local-only Python product](../decisions/local-only-python-product) decision [@release-doc] [@public-contract-tests]. The release is not done when PyPI accepts files. It is done when clean installed artifacts and the public install path can run the `codealmanac` command outside the repo developer environment [@release-doc] [@readme]. ## Preconditions -Start from a clean checkout of the release branch. `pyproject.toml` owns the package version, requires Python 3.12 or newer, and exposes exactly one console script: `codealmanac = "codealmanac.cli.main:main"` [@pyproject]. The release document states that stable releases publish from `main` to the normal PyPI release channel and that version numbers must not be reused [@release-doc]. +Start from a clean checkout of the release branch. `pyproject.toml` owns the package version, requires Python 3.12 or newer, and maps both the canonical `codealmanac` command and its short `ca` alias to `codealmanac.cli.main:main` [@pyproject]. The [public command surface](../reference/cli/public-command-surface) is the contract that release smoke should prove from installed artifacts. The release document states that stable releases publish from `main` to the normal PyPI release channel and that version numbers must not be reused [@release-doc]. -Keep the package surface narrow. Public-contract tests reject npm release language, old install surfaces, hosted commands, legacy aliases, and public SDK or MCP modules [@public-contract-tests]. If those tests fail, fix the public contract before publishing. +Keep the package surface narrow. Public-contract tests reject npm release language, old install surfaces, hosted commands, undeclared legacy aliases, and public SDK or MCP modules [@public-contract-tests]. If those tests fail, fix the public contract before publishing. ## Build And Smoke @@ -52,7 +52,7 @@ uv build --out-dir dist uvx twine check dist/* ``` -Then install both artifacts into clean Python 3.12 environments and smoke the installed command [@release-doc]. For product behavior, use the installed artifact command from those temporary environments or the user-facing installed `codealmanac` binary, not a command from the repo developer environment. +Then install both artifacts into clean Python 3.12 environments and smoke both installed command names [@release-doc]. Confirm that `codealmanac --version` and `ca --version` return the same version. For product behavior, use an installed artifact command from those temporary environments, not a command from the repo developer environment. Use current public syntax during smoke checks. `sync status` accepts `--wiki`, `--from`, and `--json`; it does not accept `--quiet` [@run-parser]. The release document may lag the parser, so trust parser-backed command syntax when a smoke command disagrees with the installed CLI [@release-doc] [@run-parser]. @@ -70,13 +70,13 @@ Do not commit tokens or print secret values. `RELEASE.md` allows credentials to ## Verify The Public Install Path -After upload, verify PyPI and the install script. `RELEASE.md` uses `python -m pip index versions codealmanac` as the basic uploaded-version check, while `pyproject.toml` defines the package's Python version floor [@release-doc] [@pyproject]. +After upload, verify PyPI. `RELEASE.md` uses `python -m pip index versions codealmanac` as the basic uploaded-version check, while `pyproject.toml` defines the package's Python version floor [@release-doc] [@pyproject]. -The final public-path check is the curl installer: +The final public-path check installs the package the way a user would, from a clean environment, and confirms the entrypoint: ```bash -curl -fsSL https://codealmanac.com/install.sh | sh +uv tool install codealmanac@latest codealmanac --version ``` -The README documents the same install path for users [@readme]. Treat the release as complete only after the installer uses the uploaded package and `codealmanac --version` reports the expected version from the user-facing binary [@readme]. +The README's quickstart uses this same `uv tool install codealmanac@latest` path [@readme]; `python -m pip install codealmanac` is the documented fallback. There is no curl-based installer — `tests/test_public_contract.py` forbids the fragment `curl -fsSL` from ever appearing in the README, so that surface must never come back as the public install path [@public-contract-tests]. Treat the release as complete only after the installed package resolves to the uploaded version and `codealmanac --version` reports the expected version from the user-facing binary [@readme]. diff --git a/almanac/guides/setup-local-automation.md b/almanac/guides/setup-local-automation.md index e0fe26f4..de7cbc3f 100644 --- a/almanac/guides/setup-local-automation.md +++ b/almanac/guides/setup-local-automation.md @@ -1,6 +1,6 @@ --- title: Setup Local Automation -topics: [guides, setup, automation] +topics: [guides, setup, automation, telemetry] sources: - id: readme type: file @@ -34,11 +34,27 @@ sources: type: file path: src/codealmanac/services/automation/defaults.py note: Default sync, garden, and update intervals. + - id: claude-transcripts + type: file + path: src/codealmanac/integrations/sources/transcripts/claude.py + note: Discovers transcripts under ~/.claude/projects. + - id: codex-transcripts + type: file + path: src/codealmanac/integrations/sources/transcripts/codex.py + note: Discovers transcripts under ~/.codex/sessions. + - id: automation-render + type: file + path: src/codealmanac/cli/render/automation.py + note: Renders launchd run-health fields for automation status. + - id: telemetry-architecture + type: wiki + path: architecture/telemetry + note: Architecture page for telemetry event, opt-out, identity, and delivery boundaries. --- # Setup Local Automation -Use this guide to install and verify CodeAlmanac's local scheduled work. Setup can install agent instructions, write the default runner config, and install scheduled `sync`, `garden`, and `update` tasks [@setup-service] [@setup-automation]. Automation is local machine state, not cloud sync, and its scheduler logs live under `~/.codealmanac/logs/` [@readme]. +Use this guide to install and verify CodeAlmanac's local scheduled work. Setup can install agent instructions, write the default runner and telemetry preferences, and install scheduled `sync`, `garden`, and `update` tasks [@setup-service] [@setup-automation]. Automation is local machine state, not cloud sync, and its scheduler logs live under `~/.codealmanac/logs/` [@readme]. When anonymous telemetry is enabled, scheduled entrypoints may send allowlisted command/lifecycle outcomes or sanitized unhandled exceptions, never wiki or transcript content [@telemetry-architecture]. The usual successful state is simple: setup has selected a runner, scheduled the tasks you want, `automation status` reports them installed, and `sync status` or `jobs` can show local lifecycle activity. For background, see [Automation and update](../architecture/setup/automation-and-update), [Config keys](../reference/config-keys), and [Run queue and sync](../architecture/lifecycle/run-queue-and-sync). @@ -50,7 +66,7 @@ For the default unattended setup, run: codealmanac setup --yes ``` -The README describes plain setup as installing local agent instructions plus the default local automation: sync, Garden, and daily package update [@readme]. In code, setup writes `auto_commit`, `harness.default`, and `harness.model` through the config service when config is available [@setup-service]. +The README describes plain setup as installing local agent instructions plus the default local automation: sync, Garden, and daily package update [@readme]. In code, setup writes `auto_commit`, `harness.default`, and `harness.model` through the config service when config is available [@setup-service]. `auto_commit` is a prompt policy read by lifecycle operations, not a Python-side committer; see [Auto-commit is prompt policy](../decisions/auto-commit-is-prompt-policy) for what the flag actually controls. Use flags to change the initial policy: @@ -60,15 +76,17 @@ codealmanac setup --yes --sync-every 5h codealmanac setup --yes --sync-off codealmanac setup --yes --garden-off codealmanac setup --yes --no-auto-update +codealmanac setup --yes --no-auto-commit +codealmanac setup --yes --no-telemetry ``` -The setup parser exposes runner choice, auto-commit policy, instruction skipping, sync interval, sync disable, Garden interval, Garden disable, and auto-update disable flags [@setup-parser]. +The setup parser exposes runner choice, auto-commit policy, instruction skipping, sync interval, sync disable, Garden interval, Garden disable, auto-update disable, and telemetry opt-out flags [@setup-parser]. Interactive setup asks about telemetry last with Yes recommended and No visible. Later, `codealmanac config set telemetry.enabled false`, `DO_NOT_TRACK=1`, or `CODEALMANAC_NO_TELEMETRY=1` disables all usage and exception capture [@telemetry-architecture]. See [Instruction installation](../architecture/setup/instruction-installation) for what gets written to a coding agent's global instructions when instructions are not skipped. ## Understand The Default Tasks Setup's default automation task list is `sync`, `garden`, and `update` [@setup-automation]. Sync scans recent local agent transcripts, Garden improves wiki structure and graph hygiene, and update keeps the local CodeAlmanac CLI package updated [@setup-automation]. -The default intervals are 5 hours for sync, 4 hours for Garden, and 1 day for update [@automation-defaults]. Setup removes tasks from the default list when `--sync-off`, `--garden-off`, or `--no-auto-update` is set [@setup-automation]. +The default intervals are 5 hours for sync, 24 hours for Garden, and 1 day for update [@automation-defaults]. Setup removes tasks from the default list when `--sync-off`, `--garden-off`, or `--no-auto-update` is set [@setup-automation]. ## Manage Automation Directly @@ -76,7 +94,7 @@ The `automation` command is read-only: its only subcommand is `status`, optional ```bash codealmanac config set automation.sync.every 5h -codealmanac config set automation.garden.every 4h +codealmanac config set automation.garden.every 24h codealmanac config set automation.update.every 24h codealmanac automation status ``` @@ -91,6 +109,12 @@ Check the scheduler state: codealmanac automation status ``` +Each installed task reports whether launchd loaded it, whether it is currently +running or idle, how many times it has run, and the last exit result +[@automation-render]. See [Automation and update](../architecture/setup/automation-and-update) +for what each `ScheduledJobState` field means and when a nonzero exit code +warrants inspecting the task log under `~/.codealmanac/logs/`. + Then check sync and run activity: ```bash @@ -100,6 +124,18 @@ codealmanac jobs The README presents `sync status`, `sync`, `automation status`, and `jobs` as the daily local surfaces for automation and lifecycle work [@readme]. If a scheduled run fails, inspect it with `codealmanac jobs show <run-id>` and `codealmanac jobs logs <run-id>`. +## Troubleshoot macOS Full Disk Access Prompts + +Sync reads transcripts from `~/.claude/projects` and `~/.codex/sessions` [@claude-transcripts] [@codex-transcripts], which macOS treats as data owned by other apps. On macOS Sonoma and later, this can trigger a repeating "would like to access data from other apps" prompt for the launchd-scheduled `sync` job, because the prompt targets the exact executable that opened the files rather than CodeAlmanac as a concept. + +Granting Full Disk Access to Codex, Terminal, or another parent app does not stop the prompt, because the scheduled job runs as its own process under the Python interpreter tied to the current install. Grant Full Disk Access (System Settings > Privacy & Security > Full Disk Access) to that specific interpreter or `codealmanac` executable, then re-apply automation: + +```bash +codealmanac config apply +``` + +The tradeoff is that Full Disk Access applies to the whole interpreter, not narrowly to CodeAlmanac, since there is no dedicated signed CodeAlmanac binary that macOS can authorize on its own. + ## Uninstall Local Artifacts To remove CodeAlmanac-owned local artifacts, run: diff --git a/almanac/reference/README.md b/almanac/reference/README.md index a73bfe8c..518687dc 100644 --- a/almanac/reference/README.md +++ b/almanac/reference/README.md @@ -77,9 +77,10 @@ output contract](cli/json-output-contract) when a command needs machine-readable output, or [Error and exit code contract](cli/error-and-exit-code-contract) when changing terminal failures [@json-output] [@error-exit]. -[Config keys](config-keys) defines `auto_commit`, `harness.default`, and -`harness.model`, including precedence between CLI flags, project config, user -config, and built-in defaults [@config-keys]. +[Config keys](config-keys) defines `auto_commit`, `harness.default`, +`harness.model`, `telemetry.enabled`, and automation keys, including precedence +between CLI flags, the one user config file, and built-in defaults; there is no +repository-level config layer [@config-keys]. ## Wiki Page Format diff --git a/almanac/reference/cli/error-and-exit-code-contract.md b/almanac/reference/cli/error-and-exit-code-contract.md index 7d2e9945..7da270d0 100644 --- a/almanac/reference/cli/error-and-exit-code-contract.md +++ b/almanac/reference/cli/error-and-exit-code-contract.md @@ -5,7 +5,11 @@ sources: - id: cli_main type: file path: src/codealmanac/cli/main.py - note: CLI entrypoint, parser invocation, syntax rendering, product error handling, and returned exit codes. + note: CLI entrypoint, parser invocation, and syntax rendering. + - id: cli_execution + type: file + path: src/codealmanac/cli/execution.py + note: Post-parse dispatch, product error handling, interrupt handling, and returned exit codes. - id: syntax_models type: file path: src/codealmanac/cli/syntax/models.py @@ -46,13 +50,13 @@ sources: # Error And Exit Code Contract -The CLI uses process exit codes and stderr as part of its public behavior. Successful commands return `0`; product errors and Pydantic validation errors caught by `main()` print `codealmanac: ...` to stderr and return `1` [@cli_main]. Parser syntax errors become shaped `SyntaxProblem` models and render as CodeAlmanac help screens on stderr with exit code `2` [@cli_main] [@syntax_models] [@syntax_render]. +The CLI uses process exit codes and stderr as part of its public behavior. Successful commands return `0`; product errors and Pydantic validation errors caught by `execute()` print `codealmanac: ...` to stderr and return `1` [@cli_execution]. Parser syntax errors become shaped `SyntaxProblem` models and render as CodeAlmanac help screens on stderr with exit code `2` before `execute()` is ever reached [@cli_main] [@syntax_models] [@syntax_render]. -The contract is intentionally small. Services raise typed product errors, renderers print command results, and the CLI entrypoint translates crossing-edge failures into stderr and integer exits [@cli_main] [@core_errors]. This keeps error handling aligned with [Terminal output](../../architecture/cli/terminal-output) and the [Public command surface](public-command-surface). +The contract is intentionally small. Services raise typed product errors, renderers print command results, and `execute()` translates crossing-edge failures into stderr and integer exits [@cli_execution] [@core_errors]. This keeps error handling aligned with [CLI adapter boundary](../../architecture/cli/adapter-boundary), [Terminal output](../../architecture/cli/terminal-output), and the [Public command surface](public-command-surface). ## Product Errors -All product errors that cross the CLI edge inherit from `CodeAlmanacError` [@core_errors]. The defined error codes are `codealmanac_error`, `already_exists`, `not_found`, `repository_not_selected`, `conflict`, `validation_failed`, and `execution_failed` [@core_errors]. The CLI currently prints the text form of these errors rather than a structured error object [@cli_main]. +All product errors that cross the CLI edge inherit from `CodeAlmanacError` [@core_errors]. The defined error codes are `codealmanac_error`, `already_exists`, `not_found`, `repository_not_selected`, `conflict`, `validation_failed`, and `execution_failed` [@core_errors]. The CLI currently prints the text form of these errors rather than a structured error object [@cli_execution]. `NoRepositorySelected` tells the user to run from a registered repository root or pass `--wiki <name>` [@core_errors]. `NotFoundError`, `AlreadyExists`, `ConflictError`, `ValidationFailed`, and `ExecutionFailed` carry resource, conflict, validation, or execution failures from services to the entrypoint [@core_errors]. @@ -62,7 +66,7 @@ Unsupported parser options are command-line syntax errors. Tests assert that rem Unknown commands and missing command-group actions also use the syntax renderer. `jobs list` and `topics list` render replacements for the valid bare commands, while forbidden top-level commands render the root command guide without exposing hidden worker commands [@syntax_catalog] [@syntax_render]. -Pydantic validation errors that occur after parsing are caught by `main()` and returned as exit code `1` with the `codealmanac:` prefix [@cli_main]. The job command test for a path-shaped run id expects no stdout and a validation message on stderr [@cli_tests]. +Pydantic validation errors that occur after parsing are caught by `execute()` and returned as exit code `1` with the `codealmanac:` prefix [@cli_execution]. The job command test for a path-shaped run id expects no stdout and a validation message on stderr [@cli_tests]. ## Validate And Health @@ -72,6 +76,6 @@ Validation issues include source shape failures, runtime-state leaks, page route ## Command-Specific Nonzero Results -Some commands return `1` as a normal command outcome without going through `main()`'s product-error catch. `uninstall` returns `1` when confirmation is declined in a non-interactive shell, and it also returns `1` when package uninstall fails [@setup_dispatch] [@cli_tests]. Setup cancellation prints a cancellation message to stderr and returns `1` [@setup_dispatch]. +Some commands return `1` as a normal command outcome without going through `execute()`'s product-error catch. `uninstall` returns `1` when confirmation is declined in a non-interactive shell, and it also returns `1` when package uninstall fails [@setup_dispatch] [@cli_tests]. Setup cancellation prints a cancellation message to stderr and returns `1` [@setup_dispatch]. These command outcomes are still terminal contracts. Scripts should check the process exit code first, then parse stdout only when the command is expected to produce a usable result. diff --git a/almanac/reference/cli/public-command-surface.md b/almanac/reference/cli/public-command-surface.md index fc660763..9abb9247 100644 --- a/almanac/reference/cli/public-command-surface.md +++ b/almanac/reference/cli/public-command-surface.md @@ -5,7 +5,7 @@ sources: - id: pyproject type: file path: pyproject.toml - note: Package script entrypoint for the CodeAlmanac CLI. + note: Package script entrypoints for the CodeAlmanac CLI. - id: repo_readme type: file path: README.md @@ -74,7 +74,7 @@ sources: # Public Command Surface -The public command surface is the set of terminal commands exposed by the `codealmanac` script. The package installs that script as `codealmanac = "codealmanac.cli.main:main"`, so the CLI parser is the user-facing contract for command names and flags [@pyproject]. Parser modules define syntax only; the [CLI adapter boundary](../../architecture/cli/adapter-boundary) explains how parsed commands cross into services and workflows. +The public command surface is the set of terminal commands exposed by the canonical `codealmanac` script and its short `ca` alias. The package maps both console-script names to `codealmanac.cli.main:main`, so they share one parser, command behavior, output contract, and exit-code contract [@pyproject]. Parser modules define syntax only; the [CLI adapter boundary](../../architecture/cli/adapter-boundary) explains how parsed commands cross into services and workflows. The root parser registers three command families: run commands, wiki commands, and admin commands [@parser_root]. The admin family delegates to config, setup, diagnostics, update, jobs, and automation parser modules [@parser_admin]. It also exposes `--version` and lists the visible top-level command names in `PUBLIC_COMMAND_METAVAR` [@parser_root]. Hidden worker commands exist for internal scheduling and queue execution, but they are removed from visible choices and from rendered syntax guidance [@parser_run] [@syntax_catalog]. @@ -91,15 +91,15 @@ Parser failures go through the custom argument parser, which classifies syntax p | `list` | List registered local wikis. | `--json` [@parser_wiki] | | `search [query]` | Search the selected wiki. | `--wiki`, `--topic`, `--mentions`, `--limit`, `--slugs`, `--json` [@parser_wiki] | | `show <page>` | Show one indexed wiki page. | `--wiki`, `--json`, `--body`, `--meta`, `--lead`, `--links`, `--backlinks`, `--files`, `--topics` [@parser_wiki] | -| `topics` | List, inspect, and mutate topics. | `show`, `create`, `describe`, `link`, `unlink`, `rename`, `delete` [@parser_wiki] | +| `topics` | List, inspect, and mutate topics; no subcommand lists all topics. | `show`, `create`, `describe`, `link`, `unlink`, `rename`, `delete` [@parser_wiki] | | `health` | Report graph and source health. | `--wiki`, `--json` [@parser_wiki] | | `validate` | Validate the local wiki and return nonzero when issues exist. | `--wiki`, `--json` [@parser_wiki] | | `reindex` | Force a full index rebuild. | `--wiki`, `--json` [@parser_wiki] | -| `serve` | Serve the local wiki viewer. | `--wiki`, `--host`, `--port`; defaults to `127.0.0.1:3927` [@parser_wiki] | +| `serve` | Serve the local wiki viewer and open it in the default browser. | `--wiki`, `--host`, `--port`, `--no-open`; defaults to `127.0.0.1:3927` and opens the browser [@parser_wiki] | | `tag <page> <topics...>` | Add topics to a page frontmatter block. | `--wiki` [@parser_wiki] | | `untag <page> <topics...>` | Remove topics from a page frontmatter block. | `--wiki` [@parser_wiki] | | `config` | Read or write user config values, or apply saved config to machine automation. | `list`, `get`, `set`, `apply`; keys cover `auto_commit`, `harness.default`, `harness.model`, and the `automation.<task>.enabled`/`automation.<task>.every` family; see [Config keys](../config-keys) [@parser_config] | -| `setup` | Install local agent instructions and scheduled automation. | `--target`, `--yes`, `--runner`, `--no-auto-commit`, `--skip-instructions`, `--no-auto-update`, `--sync-every`, `--sync-off`, `--garden-every`, `--garden-off`, `--json` [@parser_setup] | +| `setup` | Install local agent instructions and scheduled automation. | `--target`, `--yes`, `--runner`, `--no-auto-commit`, `--no-telemetry`, `--skip-instructions`, `--no-auto-update`, `--sync-every`, `--sync-off`, `--garden-every`, `--garden-off`, `--json` [@parser_setup] | | `uninstall` | Remove setup-owned local artifacts. | `--yes`, `--json` [@parser_setup] | | `doctor` | Check the local install and selected wiki. | `--wiki`, `--json` [@parser_diagnostics] | | `update` | Update the local CLI. | `--check`, `--json`; `--scheduled` is hidden [@parser_updates] | diff --git a/almanac/reference/config-keys.md b/almanac/reference/config-keys.md index 4539f777..795ca11e 100644 --- a/almanac/reference/config-keys.md +++ b/almanac/reference/config-keys.md @@ -1,6 +1,6 @@ --- title: Config Keys -topics: [reference, config, setup, harnesses] +topics: [reference, config, setup, harnesses, telemetry] sources: - id: readme type: file @@ -30,6 +30,10 @@ sources: type: file path: tests/test_config_service.py note: Tests for defaults, user-only behavior, reconciliation, and invalid values. + - id: telemetry-architecture + type: wiki + path: architecture/telemetry + note: Architecture page for telemetry policy, identity, event, and delivery boundaries. --- # Config Keys @@ -59,10 +63,11 @@ written [@config-tests]. | `auto_commit` | Boolean | `true` | `true` or `false` | | `harness.default` | Harness name | `codex` | `codex` or `claude` | | `harness.model` | Model name | `gpt-5.5` | One model from the selected harness catalog | +| `telemetry.enabled` | Boolean | `true` | `true` or `false` | | `automation.sync.enabled` | Boolean | `true` | `true` or `false` | | `automation.sync.every` | Duration | `5h` | Positive human duration | | `automation.garden.enabled` | Boolean | `true` | `true` or `false` | -| `automation.garden.every` | Duration | `4h` | Positive human duration | +| `automation.garden.every` | Duration | `24h` | Positive human duration | | `automation.update.enabled` | Boolean | `true` | `true` or `false` | | `automation.update.every` | Duration | `24h` | Positive human duration | @@ -72,6 +77,11 @@ written [@config-tests]. `harness.model` must be in the global controlled model set and must also belong to the currently selected default harness [@config-models] [@config-service]. +`telemetry.enabled` controls anonymous command, lifecycle, and sanitized crash +telemetry. Setting it to `false` takes effect on the next event. The installation +UUID remains local SQLite state rather than TOML configuration +[@telemetry-architecture]. + ## Harness Models | Harness | Allowed models | Harness default | @@ -92,13 +102,16 @@ auto_commit = true default = "codex" model = "gpt-5.5" +[telemetry] +enabled = true + [automation.sync] enabled = true every = "5h" [automation.garden] enabled = true -every = "4h" +every = "24h" [automation.update] enabled = true diff --git a/almanac/reference/harness-event-shape.md b/almanac/reference/harness-event-shape.md index 0cba6848..670bda33 100644 --- a/almanac/reference/harness-event-shape.md +++ b/almanac/reference/harness-event-shape.md @@ -11,17 +11,25 @@ sources: - id: harness-actors type: file path: src/codealmanac/services/harnesses/actors.py + - id: yoke-event-projector + type: file + path: src/codealmanac/integrations/harnesses/yoke/events.py + note: Converts Yoke EventKind values into HarnessEventKind and builds HarnessEvent from a Yoke Event. + - id: operation-harness + type: file + path: src/codealmanac/workflows/operations/harness.py + note: Filters text_delta before recording and classifies remaining events for the run log. --- # Harness Event Shape -The harness event shape is CodeAlmanac's normalized transcript format for agent runs. Provider adapters convert Codex or Claude event streams into `HarnessEvent` objects, and lifecycle code records those events without parsing provider-specific JSON [@harness-events]. This page defines the event kinds, optional payload groups, tool display fields, usage counters, failures, actor data, and agent traces used by the [harness contract](../architecture/agent-runs/harness-contract). +The harness event shape is CodeAlmanac's normalized transcript format for agent runs. The [Yoke harness boundary](../architecture/agent-runs/provider-adapters) converts Codex or Claude event streams into `HarnessEvent` objects, and lifecycle code records those events without parsing provider-specific JSON [@harness-events]. This page defines the event kinds, optional payload groups, tool display fields, usage counters, failures, actor data, and agent traces used by the [harness contract](../architecture/agent-runs/harness-contract). -A harness event always has a `kind` and non-empty `message`. All other fields are optional and depend on what the provider exposed for that event [@harness-events]. The shape is intentionally broad enough for text, tools, token usage, provider sessions, failures, terminal status, and helper-agent activity, so [provider adapters](../architecture/agent-runs/provider-adapters) can add detail without changing lifecycle workflow code. +A harness event always has a `kind` and non-empty `message`. All other fields are optional and depend on what the provider exposed for that event [@harness-events]. The shape is intentionally broad enough for text, tools, token usage, provider sessions, failures, terminal status, and helper-agent activity, so the [Yoke harness boundary](../architecture/agent-runs/provider-adapters) can add detail without changing lifecycle workflow code. ## Event Kinds -`HarnessEventKind` is the event vocabulary. These values are stable product categories, not raw provider names [@harness-events]. +`HarnessEventKind` is the event vocabulary. These values are stable product categories, not raw provider names [@harness-events]. The first 20 kinds mirror the Yoke package's own provider-neutral `EventKind` vocabulary value for value, since the [Yoke harness adapter](../architecture/agent-runs/provider-adapters) converts a Yoke `Event.kind` straight into a `HarnessEventKind` with the same string value, falling back to `unknown` when a provider reports a kind Yoke does not recognize [@yoke-event-projector]. The last three kinds (`agent_spawned`, `agent_wait_started`, `agent_completed`) are CodeAlmanac-only additions the adapter synthesizes for helper-agent lifecycle tracking; Yoke has no equivalent kind for them [@yoke-event-projector]. | Kind | Meaning | |---|---| @@ -30,15 +38,29 @@ A harness event always has a `kind` and non-empty `message`. All other fields ar | `tool_use` | A tool call started or was reported. | | `tool_result` | A tool returned output or an error. | | `tool_summary` | A compact provider or adapter summary of tool activity. | +| `tool_request` | The provider asked to run a tool and is waiting on a decision. | +| `approval_request` | The provider asked for approval before continuing an action. | +| `user_input_request` | The provider asked the user for input before continuing. | +| `request_resolved` | A prior `tool_request`, `approval_request`, or `user_input_request` was answered. | | `context_usage` | Token or context-window usage was reported. | | `provider_session` | Provider session, thread, or turn identity was announced. | | `warning` | A non-terminal warning occurred. | | `error` | An error event occurred. | | `done` | The harness run reached a terminal status. | +| `hook` | A provider-side lifecycle hook fired. | +| `rate_limit` | The provider reported a rate limit condition. | +| `goal_updated` | A provider-owned keep-working goal was set or changed. | +| `goal_cleared` | A provider-owned keep-working goal was cleared. | +| `stream_event` | A provider stream event without a more specific mapped kind. | +| `unknown` | The provider reported a kind Yoke's `EventKind` does not recognize. | | `agent_spawned` | A helper agent or child turn was started. | | `agent_wait_started` | The root turn began waiting on helper work. | | `agent_completed` | A helper agent or child turn completed. | +For `tool_request`, `approval_request`, `user_input_request`, `request_resolved`, `hook`, `rate_limit`, `goal_updated`, `goal_cleared`, and `stream_event`, `HarnessEvent` carries only `kind` and `message` today: the Yoke adapter's event projector does not copy Yoke's structured `request`, `response`, or `goal` payloads into any `HarnessEvent` field, so their detail is limited to whatever text `message` carries [@yoke-event-projector]. + +`text_delta` events reach `HarnessEvent` like any other kind, but the [operation runner](../architecture/lifecycle/operation-runner) drops them before recording: `should_record_harness_event` filters out `text_delta` so incremental streaming text never becomes a durable run event [@operation-harness]. Every other event kind becomes a run event. + ## Event Fields `HarnessEvent` keeps common event facts at the top level. Providers should fill only the fields they can support honestly [@harness-events]. diff --git a/almanac/reference/local-state-layout.md b/almanac/reference/local-state-layout.md index 34e9bb18..81b93604 100644 --- a/almanac/reference/local-state-layout.md +++ b/almanac/reference/local-state-layout.md @@ -30,6 +30,10 @@ sources: type: file path: tests/test_settings.py note: Tests for implicit and explicit local-state config paths. + - id: yoke-adapter + type: file + path: src/codealmanac/integrations/harnesses/yoke/adapter.py + note: Consumer of the harness runtime directory for Yoke provider caching. --- # Local State Layout @@ -50,6 +54,7 @@ This layout follows the local-only Python product decision: authored Markdown st | `~/.codealmanac/repos/<repo-id>/index.db` | Derived runtime | Per-repository search and graph index. | | `~/.codealmanac/update.lock` | Local runtime | Global update lock. | | `~/.codealmanac/logs/` | Local runtime | Scheduler stdout and stderr logs. | +| `~/.codealmanac/harnesses/` | Local runtime | Product-owned cache root Yoke uses to compile Claude and Codex provider resources, so provider config is never written into a repository. | `core.paths` defines the default state directory as `Path.home() / ".codealmanac"`, the default database as `~/.codealmanac/codealmanac.db`, the default config as `~/.codealmanac/config.toml`, and scheduler logs as `~/.codealmanac/logs` [@core-paths]. @@ -61,6 +66,8 @@ This layout follows the local-only Python product decision: authored Markdown st The database path must live directly inside the state directory. `LocalStatePaths` rejects a shape where the database is nested more deeply than `state_dir / "codealmanac.db"` would imply [@settings]. +`LocalStatePaths.harness_runtime_dir` is `state_dir / "harnesses"` [@settings]. The composition root passes it to both provider adapters so Yoke's native Claude and Codex compilation caches under CodeAlmanac's own local state instead of the target repository; see [Yoke harness boundary](../architecture/agent-runs/provider-adapters) for how the adapter uses this path for run caching and readiness checks [@yoke-adapter]. + ## Per-Repository Runtime Per-repository runtime files live under `state_dir / "repos" / <repo-id>` [@settings]. The index schema defines the derived index filename as `index.db` inside that runtime directory [@index-schema]. The public README documents this as `~/.codealmanac/repos/<repo-id>/index.db` [@readme]. diff --git a/almanac/reference/page-format/links-and-routes.md b/almanac/reference/page-format/links-and-routes.md index fc62e245..60089d83 100644 --- a/almanac/reference/page-format/links-and-routes.md +++ b/almanac/reference/page-format/links-and-routes.md @@ -1,6 +1,6 @@ --- title: Links And Routes -topics: [reference, page-format, links] +topics: [reference, page-format, links, pages] sources: - id: wiki_links type: file @@ -40,15 +40,13 @@ Routes come from paths under `almanac/`. An ordinary Markdown file drops its `.m ## Page Routes -`page_id_for_path()` requires a Markdown file under the repository's `almanac/` directory [@wiki_paths]. `almanac/reference/page-format/links-and-routes.md` becomes `reference/page-format/links-and-routes`; `almanac/architecture/README.md` becomes `architecture`; and `almanac/README.md` becomes `README` [@wiki_paths]. - -The page iterator walks Markdown files below `almanac/`, but skips files inside reserved source directories [@wiki_paths]. The reserved directories are `manual` and `jobs` [@wiki_paths]. That skip is a route-indexing guard; current runtime state still belongs under `~/.codealmanac/`, not in `almanac/jobs` [@live_agreement]. +A link target can only ever resolve to a route that the page iterator actually indexes. [Page identity](../../architecture/wiki/page-identity) owns the full derivation table; the one fact link resolution depends on is that the iterator skips Markdown files beneath the reserved `manual` and `jobs` source directories before those files get a slug, so links can never resolve into them [@wiki_paths]. Current runtime state still belongs under `~/.codealmanac/`, not in `almanac/jobs` [@live_agreement]. ## Resolved Markdown Links The parser reads CommonMark inline links from the page body and resolves eligible hrefs to page slugs [@wiki_links]. Relative links are resolved against the source page's route. For a normal page, the base is the page's parent folder; for a folder landing page, the base is the folder route itself [@wiki_links]. -Examples show the rule. From `architecture/indexing`, `wiki-tree` resolves to `architecture/wiki-tree`, and `../decisions/local-only-python-product` resolves to `decisions/local-only-python-product` [@wiki_links]. From the folder landing page `architecture`, `wiki-tree` resolves to `architecture/wiki-tree` [@wiki_parsing_tests]. +Examples show the rule. From `architecture/indexing`, `wiki-tree` resolves to `architecture/wiki-tree`, and `../decisions/local-first-python` resolves to `decisions/local-first-python` [@wiki_links] [@wiki_parsing_tests]. From the folder landing page `architecture`, `wiki-tree` resolves to `architecture/wiki-tree` [@wiki_parsing_tests]. ## Ignored Hrefs diff --git a/almanac/reference/runs/run-states-and-events.md b/almanac/reference/runs/run-states-and-events.md index ce8a5015..5800db8e 100644 --- a/almanac/reference/runs/run-states-and-events.md +++ b/almanac/reference/runs/run-states-and-events.md @@ -34,6 +34,14 @@ sources: type: file path: src/codealmanac/integrations/runs/process.py note: Detached process spawning, identity verification, and tree termination. + - id: jobs-dispatch + type: file + path: src/codealmanac/cli/dispatch/jobs.py + note: Jobs command dispatch and attach interruption behavior. + - id: jobs-cli-tests + type: file + path: tests/test_cli.py + note: Human, piped, and JSON CLI behavior for jobs commands. --- # Run States And Events @@ -64,7 +72,7 @@ The queue and worker path depends on this contract. Queued specs describe work t New records start as `queued` and immediately receive a status event such as `queued ingest` or `queued garden` [@run-store] [@run-transitions]. `mark_running` can move a queued run to `running`; attempting to start from another non-running status raises a conflict [@run-transitions]. Terminal statuses are `done`, `failed`, and `cancelled` [@run-models]. -`finish` writes a terminal status, summary, error, timestamps, and a matching status event. If the run is already `cancelled`, finish returns the cancelled record unchanged, so a late success or failure cannot overwrite cancellation [@run-transitions] [@run-store]. +`finish` writes a terminal status, summary, error, timestamps, a controlled failure category for failures, and a matching status event. Every later finish attempt is a no-op once the run is terminal, so retries cannot overwrite the first outcome or repeat terminal side effects [@run-transitions] [@run-store]. ## Event Kinds @@ -98,4 +106,11 @@ Tests cover queued and running cancellation, executor-tree termination, PID-reus Attach has one settle rule: if the record is terminal but the matching terminal `status` event has not appeared yet, the streamer waits one extra poll before yielding the terminal update [@run-streaming]. This keeps `jobs attach` from ending before the final status event is visible. +Pressing `Ctrl-C` while following a run detaches only the foreground +`jobs attach` command. The run is not cancelled or otherwise mutated, and the +CLI prints the explicit `codealmanac jobs cancel <run-id>` command before +exiting with status `130`. JSON attach streams receive no human detachment +text, so their NDJSON event stream remains valid [@jobs-dispatch] +[@jobs-cli-tests]. + For operator-facing recovery steps, see [Debug a failed lifecycle run](../../guides/debug-a-failed-lifecycle-run). diff --git a/almanac/topics.yaml b/almanac/topics.yaml index 8c0eaa49..ececcda1 100644 --- a/almanac/topics.yaml +++ b/almanac/topics.yaml @@ -45,16 +45,12 @@ topics: parents: [architecture] - slug: agent-runs title: Agent Runs - description: Harness contracts, provider adapters, and normalized agent events + description: Harness contracts, the Yoke harness boundary, and normalized agent events parents: [architecture] - slug: harnesses title: Harnesses description: Local Codex and Claude runner integration boundaries parents: [agent-runs] - - slug: providers - title: Providers - description: Provider-specific edges behind service-owned ports - parents: [agent-runs] - slug: yoke title: Yoke description: Packaged Yoke agent collection, harness adapter, and event projection @@ -79,14 +75,14 @@ topics: title: Product description: Product scope and public surface decisions parents: [] - - slug: release - title: Release - description: Package publishing, release smoke checks, and public install verification - parents: [product] - slug: config title: Config description: User configuration and automation preferences parents: [product, local-state] + - slug: telemetry + title: Telemetry + description: Anonymous product signals, opt-out policy, event allowlists, and delivery boundaries + parents: [product, config] - slug: repositories title: Repositories description: Repository selection, registration, and local roots @@ -98,7 +94,7 @@ topics: - slug: automation title: Automation description: Local scheduled sync, Garden, and update tasks - parents: [architecture, lifecycle] + parents: [setup, lifecycle] - slug: setup title: Setup description: Local setup, uninstall, and machine configuration workflows @@ -111,10 +107,6 @@ topics: title: Prompts description: Operation prompts and agent-facing instructions parents: [runtime-resources] - - slug: manuals - title: Manuals - description: Packaged writing manuals used by lifecycle operations - parents: [runtime-resources] - slug: health title: Health description: Wiki validation and graph health checks @@ -134,7 +126,7 @@ topics: - slug: safety title: Safety description: Mutation boundaries, validation gates, and guarded lifecycle writes - parents: [architecture, lifecycle] + parents: [lifecycle] - slug: topics title: Topics description: Topic metadata, browsing neighborhoods, and topic maintenance diff --git a/docs/bugs/codealmanac-known-bugs.md b/docs/bugs/codealmanac-known-bugs.md index 5455971d..ff3f021d 100644 --- a/docs/bugs/codealmanac-known-bugs.md +++ b/docs/bugs/codealmanac-known-bugs.md @@ -36,12 +36,12 @@ entry documents this repair. `init` announced the job id, printed the tagline ("every codebase deserves a biography"), created the job record, and only then died — in 0s — because the -harness executable can't spawn. +harness executable couldn't spawn. **Fix:** `BuildWorkflow.start` calls `HarnessesService.ensure_ready` before registering the repository or creating the run record, and -`HarnessesService.run` re-checks readiness before every harness run (covers -queued ingest/garden and sync runs picked up later by the worker). Verified +`OperationRunner` checks readiness separately before every provider invocation +(covers queued ingest/garden and sync runs picked up later by the worker). Verified end-to-end against the broken codex install: init exits 1 with a repair message, no job record, no `almanac/` scaffold. diff --git a/docs/plans/2026-07-11-jobs-attach-interrupt.md b/docs/plans/2026-07-11-jobs-attach-interrupt.md new file mode 100644 index 00000000..f51c228a --- /dev/null +++ b/docs/plans/2026-07-11-jobs-attach-interrupt.md @@ -0,0 +1,51 @@ +# Jobs Attach Interrupt + +## Scope + +Handle `Ctrl-C` while `codealmanac jobs attach <run-id>` is following a run. +Stop only the foreground attach stream, leave the underlying run untouched, and +show the exact command for cancelling that run. + +## Out of scope + +- Changing worker process isolation. +- Changing run cancellation behavior. +- Handling broken output pipes. +- Adding a new JSON event shape for a local CLI interruption. + +## Design + +`jobs attach` owns the interruption boundary because it is the only command that +can truthfully say the user stopped following a background run. It catches +`KeyboardInterrupt`, renders a concise detachment notice for human output, and +returns the conventional interrupted exit code, `130`. + +JSON attach output remains an uninterrupted NDJSON event stream: interruption +adds no non-JSON notice and is represented by exit code `130`. + +## File changes + +- `src/codealmanac/cli/dispatch/jobs.py`: catch attach interruption and return + `130`. +- `src/codealmanac/cli/render/job_logs.py`: render the human detachment and + cancellation guidance. +- `src/codealmanac/cli/render/admin.py`: export the renderer. +- `tests/test_cli.py`: cover human and JSON interruption behavior and prove the + run is not mutated. +- `almanac/reference/runs/run-states-and-events.md`: document the public attach + interruption contract. + +## Test coverage + +- No traceback or stderr output after `Ctrl-C`. +- Human output includes the exact cancellation command. +- Exit status is `130`. +- Run status remains unchanged. +- JSON output receives no human detachment text. + +## Read before coding + +- `MANUAL.md` +- `almanac/reference/runs/run-states-and-events.md` +- `src/codealmanac/cli/dispatch/jobs.py` +- `src/codealmanac/cli/render/job_logs.py` diff --git a/docs/plans/2026-07-14-section-search-design.md b/docs/plans/2026-07-14-section-search-design.md new file mode 100644 index 00000000..ada1d7a2 --- /dev/null +++ b/docs/plans/2026-07-14-section-search-design.md @@ -0,0 +1,155 @@ +# Section-level search design + +**Status:** accepted on 2026-07-14 +**Scope:** product architecture; implementation receives a separate slice plan + +## Decision + +CodeAlmanac will replace whole-page strict-AND search with section-level SQLite +FTS5 and BM25 ranking. This becomes the default product search, not a hidden +benchmark-only variant. + +The product will not add semantic retrieval in this change. The design keeps a +clean future path for hybrid lexical/semantic retrieval, but vector machinery is +not justified until measured failures show that relevant sections lack useful +lexical overlap. + +## Why the current search changes + +The current projection writes one FTS row per page containing the slug, title, +and entire body. The query builder splits every word, converts each word to a +prefix term, and joins all terms with `AND`. SQLite then ranks only the pages +that survive that filter using FTS5's default BM25 rank. + +This means natural questions can return no results because filler words and +paraphrased verbs are required even when the page contains the answer. A small +real-wiki probe demonstrated the failure shape: two natural questions returned +no results under strict AND, while an OR-oriented query over the same existing +FTS index ranked the directly relevant page first. + +The defect is therefore not “FTS instead of BM25.” FTS5 supplies the inverted +index and query engine; BM25 ranks matches. The defects are the candidate-query +policy and the page-sized indexed unit. + +## Product behavior + +The public interaction remains: + +```text +codealmanac search <question> -> ranked pages -> codealmanac show <page> +``` + +Search internally ranks page sections and then collapses them to pages. A +result retains its best matching heading and excerpt. Existing consumers that +need only the page slug remain valid; human and JSON presentation may expose +the additive match evidence deliberately when the implementation plan freezes +the terminal contract. + +Topics and file mentions remain deterministic structured filters. They do not +become text tokens and are not folded into BM25. + +## Index shape + +Markdown stays the authored source of truth. The rebuildable per-repository +SQLite index projects coherent sections derived from the existing Markdown +token stream: + +```text +page_sections +├── page_slug +├── section_id +├── heading_path +├── ordinal +└── body + +fts_sections +├── page_slug +├── page_title +├── heading +└── body +``` + +The implementation should reuse the installed `markdown-it-py` parser rather +than introduce a second Markdown parser or split headings with regular +expressions. Section identity must be deterministic across index rebuilds. + +## Query and ranking contract + +The lexical query analyzer will: + +- preserve meaningful names, identifiers, dates, acronyms, and technical terms; +- avoid requiring ordinary question scaffolding such as “how does” or “where + is” for candidate eligibility; +- use recall-oriented candidate matching rather than all-token AND; +- leave exact topic and file-reference filtering to their existing stores; +- escape all FTS syntax rather than accepting raw user query operators. + +SQLite FTS5 performs BM25 ranking. Page title and section heading should carry +more weight than section body text. Multiple strong sections from one page do +not flood the final result list: the result projection groups by page and +retains the best match evidence with deterministic tie-breaking. + +Exact weights, term policy, section-size rules, and tie-breaks belong in the +implementation plan and must be frozen before the Memento scored run. They are +search policy, not user-facing configuration knobs. + +## Responsibility boundaries + +```python +query = search_queries.analyze(request.query) +matches = index.search_sections(query, request.topics, request.mentions) +pages = search_results.collapse_by_page(matches) +``` + +- Wiki parsing owns Markdown meaning. +- Index projection owns derived section rows and FTS persistence. +- Index read views own SQL matching and BM25 ordering. +- Search service owns the page-oriented product result. +- CLI, viewer, and JSON edges render the same typed result; they do not rank. + +Do not add a generic retriever registry for one implementation. The existing +service/store boundary is the seam. If semantic retrieval later becomes a +second real candidate generator, introduce the smallest service-owned port at +that time and fuse normalized ranked lists above persistence integrations. + +## Future hybrid threshold + +Dense retrieval is justified only when an auditable query set shows repeated +cases where: + +1. the wiki contains a relevant section; +2. section-level lexical search fails to place it inside the useful context + budget because meaningful lexical overlap is absent; and +3. a hybrid arm improves both real CodeAlmanac queries and declared benchmark + categories without materially harming exact identifier, path, error-string, + or configuration-key searches. + +If justified, semantic retrieval supplements lexical candidates. It does not +replace them. Ranked lists should be fused with a score-independent method such +as reciprocal rank fusion before any optional reranking. + +## Validation + +The implementation must preserve existing topic/mention behavior and terminal +output unless an explicit additive output contract is accepted. Validation +must include: + +- strict-AND regression questions that currently return no results; +- titles, headings, identifiers, dates, paths, and exact error strings; +- deterministic section identities and index rebuilds; +- multiple matching sections collapsing to one page; +- topic and mention filters composed with text search; +- empty and punctuation-only queries; +- human, JSON, viewer, and service-level result parity; +- latency on a real wiki and an enlarged synthetic wiki; +- Memento stock-search versus section-search comparison under frozen budgets. + +## Explicit non-goals + +- embedding generation or vector storage; +- an external search server; +- LanceDB, Tantivy, Elasticsearch, Meilisearch, or a Python BM25 reimplementation; +- LLM query rewriting; +- agentic iterative retrieval; +- user-configurable ranking weights; +- changing Markdown page identity or the `search -> show` mental model. diff --git a/docs/plans/2026-07-15-ca-cli-alias.md b/docs/plans/2026-07-15-ca-cli-alias.md new file mode 100644 index 00000000..89fb6b71 --- /dev/null +++ b/docs/plans/2026-07-15-ca-cli-alias.md @@ -0,0 +1,106 @@ +# CA CLI Alias Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Install `ca` as a short alias for the canonical `codealmanac` command. + +**Architecture:** Keep one CLI implementation and expose it through two Python console-script names. Both entry points target `codealmanac.cli.main:main`, so parsing, dispatch, output, errors, and exit codes remain identical without adding alias-aware runtime branches. + +**Tech Stack:** Python packaging (`pyproject.toml`), pytest, uv, Markdown public-contract documentation. + +--- + +### Task 1: Change the package entry-point contract + +**Files:** +- Modify: `tests/test_public_contract.py` +- Modify: `pyproject.toml` + +**Step 1: Write the failing test** + +Rename the entry-point contract test and require both names to target the same callable: + +```python +def test_public_entry_points_include_ca_alias(): + pyproject = tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text()) + + scripts = pyproject["project"]["scripts"] + + assert scripts == { + "ca": "codealmanac.cli.main:main", + "codealmanac": "codealmanac.cli.main:main", + } +``` + +**Step 2: Run the test to verify it fails** + +Run: `uv run pytest tests/test_public_contract.py::test_public_entry_points_include_ca_alias -q` + +Expected: FAIL because `pyproject.toml` does not yet declare `ca`. + +**Step 3: Add the minimal package entry point** + +Add the second script in `pyproject.toml`: + +```toml +[project.scripts] +ca = "codealmanac.cli.main:main" +codealmanac = "codealmanac.cli.main:main" +``` + +**Step 4: Run the focused test** + +Run: `uv run pytest tests/test_public_contract.py::test_public_entry_points_include_ca_alias -q` + +Expected: PASS. + +### Task 2: Update the public contract documentation + +**Files:** +- Modify: `README.md` +- Modify: `RELEASE.md` +- Modify: `almanac/reference/cli/public-command-surface.md` +- Modify: `almanac/guides/release-package.md` +- Modify: `tests/test_public_contract.py` + +**Step 1: Document command identity** + +Keep `codealmanac` canonical and describe `ca` as its short alias. Remove statements that the package exposes exactly one command or no compatibility aliases. + +**Step 2: Guard the README contract** + +Require the README fragment `Short alias: `ca`` in `README_REQUIRED_FRAGMENTS`. + +**Step 3: Run the public-contract tests** + +Run: `uv run pytest tests/test_public_contract.py -q` + +Expected: PASS. + +### Task 3: Verify the built artifacts + +**Files:** +- Verify only: repository and temporary build/install directories + +**Step 1: Run repository gates** + +Run: `uv run pytest && uv run ruff check . && git diff --check` + +Expected: all tests pass, Ruff reports no errors, and Git reports no whitespace errors. + +**Step 2: Build and inspect artifacts** + +Run: `uv build --out-dir <temporary-directory>` + +Expected: wheel and source distribution build successfully. + +**Step 3: Smoke both installed commands** + +Install the wheel into a temporary virtual environment, then run: + +```bash +codealmanac --version +ca --version +``` + +Expected: both commands exit zero and print the same CodeAlmanac version. diff --git a/docs/plans/2026-07-15-cli-telemetry-live-agreement.md b/docs/plans/2026-07-15-cli-telemetry-live-agreement.md new file mode 100644 index 00000000..e0a8e744 --- /dev/null +++ b/docs/plans/2026-07-15-cli-telemetry-live-agreement.md @@ -0,0 +1,391 @@ +# CLI Telemetry Live Agreement + +Last updated: 2026-07-16 + +This is the running implementation context for the CLI telemetry goal. Update it +after every meaningful implementation, external PostHog, review, or verification +checkpoint. Code and tests remain the source of truth. + +## Goal + +Ship unobtrusive, opt-out CLI product telemetry and error tracking end to end, +including a fresh PostHog US project, a `CLI Product Health` dashboard, privacy +proofs, automated tests, and disposable real-world command/background-job smoke +tests. + +## Git state + +- Development branch: `codex/cli-telemetry` +- Current rebased base: `origin/main` at `d2b8b8f8` +- Plan commit: `a40ef638` +- Onboarding/config commit: `f64c893d` +- Almanac architecture commit: `4e8e9661` +- Telemetry implementation/test commit: `8c193253` +- Exact production QA documentation commit: `d80b1aee` +- Linux CI test-isolation fix: `4edfe89a` +- Review-fix plan commit: `22e81980` +- Main merge commit: `ddee1934` +- Diligent review-fix commit: `463de759` +- Follow-up review-fix plan commit: `09964628` +- Follow-up review-fix commit: `438d789b` +- Latest-main rebase record: `6523627f` +- Unrelated untracked user files must not be staged or modified. + +## Settled product decisions + +- Setup order is provider, model, instructions, wiki maintenance, product + updates, agent change handling, telemetry permission. +- Telemetry is the final screen, defaults to a benefit-led recommended Yes, and + keeps a visible functional No. +- `setup --yes` chooses the saved/default Yes; `--no-telemetry` explicitly opts + out. No first-run notice machinery. +- The anonymous identity is a stable UUID stored in local SQLite. Config TOML + stores only `telemetry.enabled`. +- UUID-backed PostHog person profiles are enabled. Without future login they have + no name or email. GeoIP is disabled. +- A future login will alias the installation UUID to an opaque account ID; the + typed identity seam already preserves this path. +- Capture all public top-level commands with controlled actions. Never capture + raw args, queries, selectors, paths, repository/run IDs, prompts, source, + transcripts, Git data, or provider session IDs. +- Capture lifecycle outcomes for build/ingest/garden exactly once, including + background failures and cancellations. +- Capture real unhandled foreground, worker, and executor exceptions in PostHog + Error Tracking. Do not turn normal provider or validation failures into fake + exceptions. Send only exception type, stable fingerprint, and CodeAlmanac + module/function/line frames; never send exception text, locals, code variables, + or source context. +- Telemetry is best effort and detached. It must not change output, exit status, + or command latency. No outbox/retry queue in v1. + +## Implemented and verified + +- Written implementation plan: + `docs/plans/2026-07-15-cli-telemetry.md`. +- Config model, config commands, setup request/service, seven-step wizard, + `--no-telemetry`, and exact ordering. +- Focused onboarding/config verification: 88 tests passed; focused Ruff passed. +- Typed telemetry envelope and identity model. +- Stable UUID singleton and exact-once event claims in SQLite. +- Policy reload on every event; config opt-out, `CODEALMANAC_NO_TELEMETRY`, + `DO_NOT_TRACK`, and CI suppression. +- Structural exception envelope with CodeAlmanac-only module/function/line + frames and stable fingerprint; arbitrary exception text never enters an event. +- Supported PostHog Python SDK dependency. +- Detached one-shot sender with bounded stdin JSON, no output, no waiting, + GeoIP disabled, person processing allowed, SDK exception autocapture off, and + code-variable capture off. +- Composition-root injection seam for fake senders. +- Foreground CLI completion/failure/crash capture and controlled action mapping. +- Central run telemetry emits exact-once done, failed, and cancelled outcomes; + worker and executor crashes use distinct process kinds and controlled failure + categories. +- Fresh PostHog US project `CodeAlmanac CLI` exists as project `514800` and the + public ingestion token is packaged. Project IP anonymization is enabled, + session replay remains off, and retention remains at its default. +- Real dogfood proved the stable SQLite UUID maps to one anonymous profile with + no name, email, or username. Latest events have GeoIP disabled and strip SDK + full OS/runtime/library fields before upload. +- A sanitized CodeAlmanac-frame exception created active Error Tracking issue + `019f69c3-7487-7853-81af-121da0f06d2f` without paths, secrets, locals, or code + variables. +- `CLI Product Health` dashboard `1857079` is the project default. Its original + eight tiles force-ran successfully: active installations, weekly retention, + command/action adoption, activation, command success, lifecycle outcomes and + failure categories, exception volume, and version/platform distribution. +- A structural review found and fixed six edge cases: interactive + `--no-telemetry` can no longer be reversed, the foreground process no longer + imports the PostHog SDK, exception sanitization removes generic paths and + parser-supplied values, uninstall freezes the pre-removal opt-out policy, + worker wait failures remain visible after durable completion, and a missing + current directory cannot let telemetry mask the original exception. +- Foreground interruption plus worker, executor, and scheduler crash boundaries + have focused coverage. +- The final wheel built and installed into an isolated virtualenv. Its disposable + journey verified non-interactive Yes, interactive No and Yes, explicit + `--no-telemetry`, config opt-out, one stable UUID, three worker spawns, and + successful build/ingest/garden terminal events without repo, transcript, or + session identifiers in any envelope. +- Final serial gates passed: 534 pytest tests, Ruff, `git diff --check`, wheel + build/install/import/version checks, and `codealmanac validate` over 71 pages. +- The final live PostHog audit confirmed IP anonymization, session replay off, + person-on-events enabled, an empty UUID profile property map, the active + sanitized Error Tracking issue, and successful force-refresh of all eight + dashboard tiles. All post-hardening events omit SDK full OS/Python/library + fields; the first retained pre-hardening dogfood command is the only historical + event containing them. + +## Exact final-commit production QA + +On 2026-07-16, commit `da82eac9` was rebuilt as a wheel, installed into a new +virtualenv, and exercised against live PostHog from a disposable home and repo. +This closes the gap between the earlier live transport smoke and the final +reviewed package: + +- Real `config get` and `config list` CLI processes traveled through the + detached child and appeared in PostHog under installation UUID + `fa20d035-0853-4eca-ac6c-59695dcef732`. +- The final audit found exactly three command events, two lifecycle events, and + two exceptions on one PostHog person. Its person property map remained empty. +- Command events contained the allowlisted command/action/outcome, version, + coarse platform, duration, and GeoIP-disable fields. Checked IP, GeoIP country, + email, name, argv, query, path, repository/run identifiers, prompt, + transcript, and SDK full runtime/library fields were all absent. +- Calling the same successful durable finish twice produced one `garden/done` + event. A real worker spawn exception produced one `garden/failed` event with + `internal_error`; the local delivery table contained exactly the two terminal + claims. +- The worker OSError created issue `019f69e3-9563-7a81-81cf-e630049ea96a` with + `cannot spawn executor at <path> token=<redacted>`. A real hidden executor + crash joined the existing ValueError issue. Both retained only in-package + CodeAlmanac frames and no session, URL, locals, environment, code variables, + run IDs, or repository IDs. +- Setting telemetry false and running another command left the live event count + unchanged. Each environment opt-out also ran successfully without creating a + telemetry database. Help, version, and syntax-error paths created no identity. +- Routing the detached child through a dead proxy left the foreground command's + output and zero exit code unchanged; it returned in 0.509 seconds and no event + appeared for that disposable UUID. +- Twenty concurrent first-use processes resolved one UUID, and twenty concurrent + claims produced exactly one winner. Corrupt UUID and delivery-table state did + not break a valid command or durable run. +- The wheel metadata declares Python 3.12+, contains only the public `phc_` + ingestion token, contains no `phx_` personal-key pattern, and keeps the PostHog + SDK out of the foreground import path. The full suites passed all 534 tests on + both Python 3.12.10 and Python 3.13.3. +- A force-blocking dashboard refresh succeeded for all eight tiles and reflected + the new installation, lifecycle outcomes, and exception volume. Project IP + anonymization remained on and session replay remained off. + +## Dashboard usage expansion + +On 2026-07-16, six additional saved insights were added to `CLI Product Health` +without changing the CLI or injecting synthetic product usage: + +- `Product installs — unique setups` (`k8VzDYDm`) counts literal anonymous + installation UUIDs (`distinct_id`) after a successful telemetry-enabled + `setup` command since launch. +- `New setup installations by day` (`6wi4W6Bd`) assigns each installation UUID to + its first successful setup day so rerunning setup does not inflate the trend. +- `Search and show usage per installation (30d)` (`XaEAPnLX`) reports runs, + unique adopting installations, and runs per adopting installation. +- `Search and show usage trend (30d)` (`rcTZtKw8`) reports daily search/show runs + alongside the unique installation count for each command. +- `Top CLI commands by usage (30d)` (`gGKrxEmH`) compares total runs with unique + installation adoption across all controlled public commands. +- `Anonymous installation usage (30d)` (`Ip1E0ddy`) lists the stable anonymous + UUID with command, search, and show counts plus first/last seen timestamps. It + includes no name, email, path, prompt, repository, or transcript data. + +The dashboard now has 14 tiles ordered from installs and activity through command +usage, lifecycle reliability, exceptions, and platform distribution. Every new +query was executed independently, and a final force-blocking refresh succeeded +for all 14 tiles. Later exact-wheel QA populated these views with one real setup, +four searches, one show, and real lifecycle/error events from disposable installs. + +## Diligent review hardening + +On 2026-07-16, five review findings were checked against the intended design and +accepted because each exposed a real privacy, consent, boundary, ledger, or +analytics gap: + +- Exception capture is now structural and non-throwing. It never calls + `str(error)` or sends free-form error text. +- Lifecycle telemetry runs only for the first durable non-terminal-to-terminal + transition. An opted-out transition cannot be replayed after re-enabling. +- PostHog `before_send` rebuilds properties from the typed event's exact key set, + so new SDK context fields cannot bypass the allowlist. +- Failed runs persist their controlled failure category in the run ledger; + telemetry reads the durable result instead of receiving telemetry-only state. +- Foreground commands returning exit code `130`, including the real `jobs attach` + detachment path, are recorded as interrupted rather than failed. + +The branch also merged current `origin/main`; the dependency resolution keeps +both main's `filelock` requirement and telemetry's PostHog requirement. + +Local review-fix verification passed 547 tests on Python 3.12.10 and Python +3.13.3, Ruff, `git diff --check`, and `codealmanac validate` over 71 pages. A +real PostHog 7.25 client with its network submitter mocked proved that the final +SDK payload contains exactly the validated event properties; a separate +red/green test proves a failing property rebuild drops the event rather than +letting the SDK send its unmodified context. The final wheel built, installed in +an isolated Python 3.12 environment, and passed version, config, and dependency +smokes with telemetry disabled. + +PR #36 became mergeable after the main merge. GitHub's package check and both +test jobs passed for review-fix commit `463de759`. + +## Follow-up review hardening + +On 2026-07-16, two follow-up findings were reproduced and accepted with narrower +architectural choices than the suggested implementations: + +- A telemetry-only `read_spec` failure could escape after a terminal transition + had committed. `RunsService` now wraps the complete supporting read and capture + path, and `TelemetryService` also contains lifecycle shaping failures. The run + spec was deliberately not added to transition results because cancellation + results reach public JSON and specs contain source inputs and guidance. +- Broad exception and traceback matching confused harness readiness, provider + execution, source preparation, indexing, and wiki validation. Failure writes + now receive the explicit workflow phase. `HarnessUnavailable` distinguishes a + failed readiness check, and `source_preparation` records ingest resolution and + runtime-inspection failures. + +Focused regression, workflow, architecture, and telemetry verification passed +133 tests. The full suite passed 554 tests on Python 3.12.10 and Python 3.13.3, +along with Ruff, `git diff --check`, and `codealmanac validate` over 71 pages. +The wheel built, installed into an isolated Python 3.12 environment, and passed +version, config, and `source_preparation` enum smokes with telemetry disabled. + +GitHub's package check and both test jobs passed for follow-up review-fix commit +`438d789b`; PR #36 remained mergeable. + +## Completion state + +The original implementation, disposable smoke testing, privacy audit, PostHog +dashboard, and diligent-review fixes are on `codex/cli-telemetry`. The third +review fixes have passed full local, package, and GitHub verification. PR #36 +remains draft and mergeable. Unrelated user files remain untouched and +untracked. + +## Third review hardening + +On 2026-07-16, three further findings were reproduced and accepted with bounded +architectural fixes: + +- Lifecycle telemetry now rejects unknown or harness-incompatible model values + against the central catalog. Validation stays at the outbound typed boundary, + not in durable `RunSpec`, so historical queued records remain readable. +- `OperationRunner.fail` computes a non-throwing summary and independently + best-efforts the readable error event and authoritative failed transition. An + event-store failure can no longer leave a run durably running or replace its + phase category. +- `HarnessesService.ensure_ready` and `run_ready` make workflow phase explicit. + Adapter exceptions are `provider_execution` after readiness, while a typed + caller event-sink failure remains `internal_error`. + +The implementation plan is +`docs/plans/2026-07-16-cli-telemetry-third-review-fixes.md`. Focused telemetry, +workflow, and harness verification passed 71 tests. The full suite passed 562 +tests on Python 3.12.10 and Python 3.13.3, along with Ruff, `git diff --check`, +and `codealmanac validate` over 71 pages. A clean wheel build omitted stale +ignored build output, installed into an isolated Python 3.12 environment, and +passed version, config, and unknown-model privacy-boundary smokes with telemetry +disabled. GitHub's package check and both test jobs passed for implementation +commit `fc48144b`; PR #36 remained mergeable. + +## Final documentation review + +A final P3 review found one shipped manual example that still named the retired +combined `harnesses.run(...)` call. `src/codealmanac/manual/how-to-write.md` now +teaches the explicit `ensure_ready(...)` then `run_ready(...)` stages used by +`OperationRunner`; no runtime behavior changed. + +## Latest-main reconciliation + +PR #36 was rebased onto `origin/main` at `c594aec1`, which includes the macOS +background-item setup work from `59ccd345`. The setup conflicts preserve both +features: every choice screen uses the dynamic six-or-seven-step denominator, +the Product updates screen renders the selected background-item notice, and +telemetry remains the optional final screen. Notice construction lives with the +other background-item render policy instead of making `setup_tui.py` oversized. + +The combined interactive setup regression verifies `[1/7]` through `[7/7]`, +runner/model/instructions ordering, the telemetry choice, and the selected +background-item notice in one journey. Rebase verification passed all 564 tests +on Python 3.12.10 and Python 3.13.3, Ruff, `git diff --check`, and +`codealmanac validate` over 71 pages. A tree comparison against the previous PR +head plus latest main found no runtime drift. + +## Final rebased end-to-end certification + +On 2026-07-16, initial rebased head `056d2853` was built as a clean `0.4.6` wheel and +installed into a new Python 3.12 virtualenv. All product state and repositories +used disposable directories; the real user registry, schedules, configuration, +and unrelated worktree files were untouched. + +- A clean `setup --yes` saved telemetry on, installed isolated Codex + instructions, and created one stable UUID, + `ca8a7992-044a-4a50-aa5d-8898322aabe5`. Help, version, and setup help created + no pre-consent state. +- A real pseudo-terminal setup traversed provider, model, agent instructions, + maintenance, product updates, change handling, and telemetry as steps one + through seven. The final screen displayed both the recommended Yes/privacy + copy and functional No. Selecting No saved `telemetry.enabled = false` and + created no identity table. +- The installed wheel ran real logged-in Codex build, ingest, and garden jobs to + durable `done`; their PostHog lifecycle events arrived as `build/done`, + `ingest/done`, and `garden/done`. A queued garden cancellation emitted exactly + one `garden/cancelled` event across two cancel attempts. +- The disposable wiki passed validation and supported real health, list, topics, + search, and show reads. Parser-to-event validation covered all 38 public + command/action combinations; hidden worker/executor/scheduler commands remain + excluded from product-command events. +- A background worker spawn failure reached durable `failed` with + `internal_error`, produced one lifecycle event, and created/updated PostHog + issue `019f69e3-9563-7a81-81cf-e630049ea96a`. A real foreground search crash + produced `outcome=crashed` plus issue + `019f6ca2-0ca4-7321-aab9-a787023a16ae`. Both sampled issues contain only the + exception type/fingerprint and CodeAlmanac module/function/line frames. +- Across the 155 exact-UUID events inspected, forbidden query, path, repository, + run, prompt, transcript, provider-session, name, email, username, URL, session, + IP, and SDK context properties all counted zero. Private query and worker-error + test markers also counted zero. Every inspected person was anonymous and had + no name, email, or username. +- Twenty simultaneous first-use commands produced 20 events under one UUID and + one anonymous person. A dead-proxy command returned success in 0.454 seconds + and produced no event. `CODEALMANAC_NO_TELEMETRY`, `DO_NOT_TRACK`, and `CI` + each suppressed state creation. An opted-out terminal transition was not + replayed after re-enabling. +- Corrupt identity data did not affect a valid command. A deliberately broken + telemetry-delivery table did not prevent a separate run from committing + `done`. These failures remained telemetry-only. +- The dashboard install queries were corrected to count/group literal + installation UUID `distinct_id` values rather than relying on current + one-to-one PostHog person IDs. A fresh force-blocking run succeeded for all 14 + tiles: installs reported one setup; search/show reported 4/1 runs across 2/1 + installations; lifecycle outcomes included the real done, cancelled, and + failed journeys; exception volume included both foreground and background + failures; the per-installation table showed the primary UUID with 125 commands, + three searches, and one show. +- The exact wheel declares compatible dependencies, passes `uv pip check`, + contains the public ingestion token but no personal API-key pattern, and omits + stale build modules. GitHub's package check and both Python test jobs passed on + the exact rebased head; PR #36 was draft, mergeable, and based on current main. + +While final QA was running, main advanced once more to `d2b8b8f8` with shipped +agent-guide citation text and its test. The branch rebased cleanly with no +conflicts; comparison with the first certified tree showed only those three main +files changed and no telemetry/runtime drift. Runtime head `94147e47` then passed +all 564 tests on Python 3.12.10 and Python 3.13.3, Ruff, 71-page Almanac +validation, `git diff --check`, clean sdist/wheel construction, fresh Python 3.12 +installation, and dependency checking. A command from that exact wheel arrived +in PostHog under disposable UUID `162293b9-5f93-43dc-80a0-80021ab87b43` as +`config/list/success` on version `0.4.6`, with GeoIP disabled and no SDK library, +IP, or query property. Its person remained anonymous with no name, email, or +username. A final force-blocking dashboard run returned all 14 insights without +error: one product install, ten active installations, four searches across two +installations, one show, the expected lifecycle outcome rows, and six sanitized +exceptions affecting four disposable installations. + +## Lockfile hygiene review + +A final P3 review correctly identified that the telemetry branch had retained a +revision-2 lockfile generated by local `uv 0.6.16`, while current main uses +revision 3. The raw `uv.lock` diff was 568 additions and 509 deletions even +though its normalized dependency change was only PostHog and four transitive +packages. + +Modern `uv` preserves an existing compatible lock revision, so rerunning it on +the branch's revision-2 file was insufficient. The lock was mechanically seeded +from current main and regenerated with `uv 0.11.29`. The resulting revision-3 +lock keeps `upload-time` metadata and differs from main by exactly 59 additions, +with no deletions: `posthog`, `backoff`, `distro`, `requests`, `urllib3`, and the +two CodeAlmanac PostHog dependency edges. + +Both modern and repository-local `uv lock --check` pass. Modern locked sync +dry-run and local `uv sync --locked` make no changes. The exact lock passes all +564 tests on Python 3.12.10 and Python 3.13.3, Ruff, 71-page Almanac validation, +`git diff --check`, and a clean modern-uv sdist/wheel build. No project-wide uv +pin was added because that is a separate tooling-policy decision; the review +noise is removed without broadening this telemetry fix. diff --git a/docs/plans/2026-07-15-cli-telemetry.md b/docs/plans/2026-07-15-cli-telemetry.md new file mode 100644 index 00000000..45b7aee7 --- /dev/null +++ b/docs/plans/2026-07-15-cli-telemetry.md @@ -0,0 +1,284 @@ +# CLI Telemetry Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add privacy-conscious PostHog telemetry that shows how the CLI is used, with a strongly recommended but genuine opt-out as the final onboarding choice. + +**Architecture:** A typed telemetry service owns policy, identity selection, and event construction; a local SQLite store owns the anonymous installation identity; and a PostHog adapter sends allowlisted events from a detached child process. The setup wizard collects `telemetry.enabled` with the other user preferences; CLI and run services emit events without knowing PostHog details. A typed `TelemetryIdentity` seam keeps a future login additive: anonymous events use the installation UUID today, while a later auth service can supply an opaque account ID and link the earlier anonymous history. + +**Tech Stack:** Python 3.12, Pydantic, SQLite, PostHog Python SDK, argparse, pytest. + +--- + +## Product decisions + +- Use a fresh US PostHog project named `CodeAlmanac CLI`. Do not reuse the existing CodeAlmanac project. +- Telemetry is enabled by default. Interactive onboarding presents a real Yes/No choice, with Yes selected and marked recommended. +- `codealmanac setup --yes` keeps telemetry enabled without another prompt. +- `codealmanac setup --yes --no-telemetry` provides a non-interactive opt-out. +- Users can change the choice later with `codealmanac config set telemetry.enabled true|false`. +- There is no separate first-run notice, notice timestamp, delayed activation, or notice fallback. +- A previous opt-out is preserved when setup is rerun unless the user explicitly changes the telemetry choice. +- Telemetry is best effort. It must never change command output, exit status, or application behavior. +- Preserve a future login path. PostHog-specific identity selection must not leak into CLI dispatch, run services, or event models. +- Create a PostHog person profile for each installation UUID. Until login exists, that profile contains no name, email, provider identity, or other personal property. + +## Onboarding contract + +The interactive setup sequence is exactly: + +1. AI provider +2. Provider model +3. Add CodeAlmanac instructions to `AGENTS.md` / `CLAUDE.md` +4. Wiki maintenance +5. Product updates +6. Agent change handling +7. Telemetry permission + +The final screen uses this product shape: + +```text +[7/7] Help improve CodeAlmanac + +Share anonymous usage so we can focus on what matters and fix broken +experiences faster. + +╭──────────────────────────────────╮ ╭──────────────────────────────────╮ +│ Yes, help improve CodeAlmanac │ │ No thanks │ +│ Recommended │ │ Do not share anonymous usage │ +│ │ │ │ +│ Never code, prompts, transcripts,│ │ You can change this later in │ +│ CLI arguments, or local data. │ │ CodeAlmanac config. │ +╰──────────────────────────────────╯ ╰──────────────────────────────────╯ +``` + +The presentation may make Yes visually prominent through initial selection, the `Recommended` label, and benefit-led copy. Both options remain visible, accurate, and equally operable. + +## Data contract + +Persist one random installation UUID in `~/.codealmanac/codealmanac.db`. Never derive it from the user, machine, home directory, repository, or provider account. + +Represent capture identity with a typed boundary: + +```python +class TelemetryIdentity(CodeAlmanacModel): + installation_id: UUID + distinct_id: str + kind: Literal["anonymous", "authenticated"] +``` + +For v1, `distinct_id` is the installation UUID and `kind` is `anonymous`. The telemetry service resolves this identity and passes it to the sender; the PostHog adapter never decides who the event belongs to. + +Future login extends the same boundary without replacing telemetry: + +1. Before login, capture with the anonymous installation UUID. +2. On first login, link that UUID to the account's opaque internal user ID with a PostHog identity/alias event. +3. After login, capture with the opaque user ID while retaining `installation_id` for installation-level analysis. +4. On logout or account switching, rotate the anonymous distinct ID before capturing unauthenticated activity so different accounts on one machine are not merged. + +Do not use email, name, provider account ID, or another directly identifying value as the PostHog distinct ID. The future authentication service owns the canonical opaque user ID. Identity linking remains subject to `telemetry.enabled`; opting out suppresses capture and linking alike. + +Usage events create and update the UUID-backed installation profile. PostHog GeoIP enrichment remains disabled. PostHog Error Tracking is enabled for unhandled CLI, worker, and executor crashes; code-variable/local-variable capture remains disabled. + +### `cli command completed` + +Allow only: + +- `command`: top-level public command +- `action`: controlled canonical action, never an argument or user-provided value +- `outcome`: `succeeded`, `failed`, `crashed`, or `interrupted` +- `exit_code` +- `duration_bucket`: `<250ms`, `250ms-1s`, `1-10s`, `10-60s`, `1-5m`, or `5m+` +- `cli_version` +- `os_family` and `os_major` +- `architecture` +- `python_version`: major and minor only +- `interactive` + +Capture all public top-level commands: `init`, `ingest`, `garden`, `sync`, `list`, `search`, `show`, `topics`, `health`, `validate`, `reindex`, `serve`, `tag`, `untag`, `config`, `setup`, `uninstall`, `doctor`, `update`, `jobs`, and `automation`. + +For commands with meaningful controlled branches, normalize `action` as follows: + +- `sync`: `run` or `status` +- `topics`: `list`, `show`, `create`, `describe`, `link`, `unlink`, `rename`, or `delete` +- `config`: `list`, `get`, `set`, or `apply` +- `jobs`: `list`, `show`, `logs`, `attach`, or `cancel` +- `automation`: `status` +- `update`: `run`, `check`, or `scheduled` +- Commands without a subcommand use their command name as the action. + +Do not emit command events for hidden `__*` commands, parser syntax failures, help, or version output. Normalize the `ca` and `codealmanac` entrypoints to the same command/action values. For `setup`, evaluate the saved choice after setup completes so choosing No does not emit the setup event. Do not derive `action` from positional arguments, free-form values, page selectors, search queries, or uncontrolled flags. + +### PostHog `$exception` + +Capture unhandled exceptions at the outer CLI, worker, and executor boundaries before preserving the existing failure behavior. Allow only: + +- Exception type +- Redacted and length-bounded exception message +- Sanitized stack frames with CodeAlmanac module/function/line information +- Stable error fingerprint +- Top-level command or hidden process kind +- CLI version, OS family, architecture, and Python major/minor + +Replace home directories, repository paths, temporary paths, URLs containing credentials, tokens, and command arguments before the payload reaches the sender. Do not capture frame locals, code variables, environment variables, request/input objects, raw CLI arguments, source files from the user's repository, or handled product errors. Telemetry opt-out suppresses exception capture too. + +### `lifecycle run completed` + +Allow only: + +- `run_kind`: `build`, `ingest`, or `garden` +- `status`: `done`, `failed`, or `cancelled` +- `failure_category`: present only for failed runs; one of `harness_readiness`, `provider_execution`, `mutation_safety`, `wiki_validation`, `indexing`, or `internal_error` +- `harness`: controlled harness kind +- `model`: controlled configured model +- `duration_bucket` +- `cli_version` + +Emit once when a run first enters a terminal state. Idempotent finish or cancellation calls must not duplicate the event. A normal provider or validation failure emits the failed lifecycle event without inventing an exception. When a real Python exception caused the failure, emit both the lifecycle event and a PostHog `$exception`. + +### Prohibited data + +Never send raw CLI arguments, source code, unsanitized paths, repository names or IDs, run IDs, page titles, prompts, transcripts, guidance, handled product-error text, Git data, provider session IDs, IP-derived properties, tokens, environment variables, frame locals, or arbitrary free-form properties. The only exception text allowed is the bounded, redacted unhandled-crash message and sanitized stack shape defined above. + +## Task 1: Record the product exception + +**Files:** + +- Modify: `docs/python-port-live-agreement.md` +- Modify: `almanac/decisions/local-only-python-product.md` +- Modify: `almanac/architecture/setup/README.md` + +1. Add a dated agreement that anonymous product telemetry is the only remote product exception. +2. State that lifecycle work, wiki content, transcripts, and repository data remain local. +3. Replace claims that the product performs no telemetry or cloud capture with the narrower accurate contract. +4. Run `codealmanac validate` and confirm the wiki remains valid. + +## Task 2: Add the telemetry configuration contract + +**Files:** + +- Modify: `src/codealmanac/services/config/models.py` +- Modify: `src/codealmanac/services/config/requests.py` +- Modify: `src/codealmanac/services/config/service.py` +- Test: `tests/test_config_service.py` + +1. Write failing tests for `telemetry.enabled`, config list/get/set, invalid boolean values, and full setup updates preserving the field. +2. Add `TelemetryConfig(enabled: bool = True)` to `UserConfig` and `ConfigKey.TELEMETRY_ENABLED = "telemetry.enabled"`. +3. Add `telemetry` to `UpdateUserConfigRequest`, config entries, scalar updates, and TOML updates. +4. Run the focused tests and confirm they pass. + +## Task 3: Reorder onboarding and add permission + +**Files:** + +- Modify: `src/codealmanac/cli/dispatch/setup_wizard/models.py` +- Modify: `src/codealmanac/cli/dispatch/setup_wizard/options.py` +- Modify: `src/codealmanac/cli/dispatch/setup_tui.py` +- Modify: `src/codealmanac/cli/render/setup/screens.py` +- Modify: `src/codealmanac/cli/parser/setup.py` +- Modify: `src/codealmanac/cli/dispatch/setup.py` +- Modify: `src/codealmanac/services/setup/requests.py` +- Modify: `src/codealmanac/services/setup/service.py` +- Test: `tests/test_cli.py` +- Test: `tests/test_setup_service.py` + +1. Write failing tests that capture the exact seven-screen order and telemetry choices. +2. Move provider and model to steps 1 and 2; move the agent-instructions target screen to step 3. +3. Change the renderer’s progress total from six to seven. +4. Add the final telemetry options with Yes first, initially selected for a new user, and `Recommended` in its copy. +5. Add `telemetry_enabled` to `SetupSelections` and `RunSetupRequest`, then persist it through `SetupService`. +6. Add `--no-telemetry`. It forces false and suppresses the telemetry screen when supplied explicitly. +7. Keep `--yes` and `--json` non-interactive. They use the current saved choice, which is true for a new user; `--no-telemetry` overrides it. +8. Verify that rerunning setup preserves an existing false choice unless the interactive screen or flag explicitly changes it. + +## Task 4: Add typed telemetry state and policy + +**Files:** + +- Create: `src/codealmanac/services/telemetry/models.py` +- Create: `src/codealmanac/services/telemetry/ports.py` +- Create: `src/codealmanac/services/telemetry/service.py` +- Create: `src/codealmanac/services/telemetry/store.py` +- Test: `tests/test_telemetry_service.py` + +1. Write failing tests for a stable random installation UUID, typed anonymous identity, strict event models, disabled capture, and swallowed sender failures. +2. Store the singleton installation UUID in the local SQLite database using a telemetry-owned table. +3. Define `TelemetryIdentity`, a `TelemetrySender` port that receives the resolved identity, and closed Pydantic event models. Do not expose a loose property dictionary at the service boundary. +4. Make the service own identity resolution and reload user config at capture time so an opt-out takes effect immediately. Leave identity linking as a future additive service verb; do not implement login or emit alias events in v1. +5. Disable capture when `telemetry.enabled` is false, `CODEALMANAC_NO_TELEMETRY` is truthy, `DO_NOT_TRACK` is truthy, or CI is detected. +6. Ensure telemetry policy and sender failures never escape into the product command. + +## Task 5: Add the detached PostHog adapter + +**Files:** + +- Create: `src/codealmanac/integrations/telemetry/posthog.py` +- Create: `src/codealmanac/integrations/telemetry/sender.py` +- Modify: `pyproject.toml` +- Modify: `uv.lock` +- Test: `tests/test_posthog_telemetry.py` + +1. Write failing tests for the detached command, serialized allowlisted envelopes, exception redaction, and child PostHog client settings. +2. Add the supported PostHog Python SDK dependency. +3. Spawn a one-shot child with a new session, closed file descriptors, and stdout/stderr redirected to null. The parent must not wait or communicate after handing off the bounded payload. +4. In the child, validate the typed payload, allow person-profile processing for the installation UUID, disable GeoIP, submit exception envelopes to PostHog Error Tracking, send once, shut down, and suppress every failure. +5. Keep PostHog code-variable/local-variable capture disabled. The parent process supplies the already-sanitized traceback; the detached sender must not inspect its own frames or environment. +6. Do not add an outbox or retry queue in v1. +7. Package only the fresh project’s public ingestion token; never ship a PostHog personal API key. + +## Task 6: Wire command and lifecycle events + +**Files:** + +- Modify: `src/codealmanac/app.py` +- Modify: `src/codealmanac/cli/main.py` +- Modify: `src/codealmanac/services/runs/service.py` +- Test: `tests/test_cli.py` +- Test: `tests/test_runs_service.py` + +1. Write failing tests with a fake sender for successful, failed, crashed, and interrupted commands, safe canonical actions, plus a separate sanitized exception envelope for unhandled crashes. +2. Add telemetry to `AppAdapters`, `Services`, and `CodeAlmanac`; wire the real adapter only in the composition root. +3. Time and classify public command completion without changing existing exception handling, output, or exit codes. Derive `command` and `action` only from parser-owned finite choices. Capture unhandled exceptions at the outer CLI boundary, send them best effort, and re-raise unchanged. +4. Re-read configuration after `setup` and `config set` before deciding whether to emit their command event. +5. Write failing tests for done, failed, and cancelled run transitions, controlled failure-category mapping, exception-backed failures, and idempotent repeats. +6. Emit lifecycle completion only after a durable non-terminal-to-terminal transition. Read the durable run spec for controlled harness/model properties and map failures to the finite category enum without sending the stored error text. +7. Install equivalent unhandled-exception boundaries for detached worker and executor entrypoints so background crashes appear in PostHog Error Tracking without exposing run or repository identity. + +## Task 7: Document privacy and opt-out + +**Files:** + +- Modify: `README.md` +- Modify: `almanac/reference/config-keys.md` +- Modify: `almanac/guides/setup-local-automation.md` +- Modify: `almanac/guides/demo-codealmanac-in-launch-video.md` + +1. Add a concise Telemetry section explaining the benefit, exact collected fields, redacted crash reporting, prohibited data, and PostHog destination. +2. Document interactive No, `setup --yes --no-telemetry`, config opt-out, and environment opt-outs. +3. Remove public claims that CodeAlmanac performs no telemetry at all. +4. Do not describe a first-run notice because none exists. + +## Task 8: Configure PostHog and verify release behavior + +1. Create the fresh US `CodeAlmanac CLI` project. +2. Enable project-level IP anonymization before any dogfood event. Leave default event retention unchanged. +3. Enable UUID-backed person profiles and PostHog Error Tracking. Keep GeoIP enrichment, session replay, and code-variable capture disabled; do not attach environment variables or local state to exceptions. Confirm installation profiles contain no name or email. +4. Create a `CLI Product Health` dashboard covering active installations, retention, command/action adoption, activation, command success, lifecycle success by failure category, exception volume, and version/platform distribution. +5. Run `uv run pytest`, `uv run ruff check .`, and `codealmanac validate`. +6. Build and install the wheel under a temporary `HOME`. Verify interactive Yes, interactive No, `--yes`, `--yes --no-telemetry`, and later config opt-out. +7. Inspect PostHog live events, installation profiles, and exception issues. Confirm usage events contain only allowlisted fields, profiles contain only the random UUID and controlled installation properties, exceptions contain sanitized frames without locals or user paths, and no name, email, or IP-derived property appears. + +## Acceptance criteria + +- The setup wizard follows the exact seven-step order above. +- Yes is persuasive, recommended, initially selected, and truthful; No is visible and works. +- `--yes` enables telemetry by default and `--no-telemetry` overrides it. +- There is no notice or delayed-activation machinery. +- Opt-out takes effect before the current command can emit telemetry. +- Every public command is measured with a parser-owned canonical action; arguments, queries, identifiers, and user values never become analytics properties. +- No command or run content can enter the typed event envelope. +- Failed lifecycle runs include a controlled failure category, and only genuine Python exceptions create Error Tracking issues. +- Unhandled foreground and background crashes reach PostHog Error Tracking with useful sanitized stack traces and no frame locals, secrets, or user repository data. +- Identity is resolved through `TelemetryIdentity`; future login can link the anonymous UUID to an opaque user ID without changing command or lifecycle instrumentation. +- Network failure cannot alter CLI output, latency-sensitive execution, or exit status. +- The PostHog dashboard answers whether people activate, return, use major commands, and complete lifecycle work successfully. diff --git a/docs/plans/2026-07-16-agent-guide-inline-citations.md b/docs/plans/2026-07-16-agent-guide-inline-citations.md new file mode 100644 index 00000000..760af29a --- /dev/null +++ b/docs/plans/2026-07-16-agent-guide-inline-citations.md @@ -0,0 +1,28 @@ +# Agent Guide Inline Citations Plan + +## Goal + +Tell coding agents to cite CodeAlmanac pages inline when Almanac knowledge +contributes to a final answer. + +## Scope + +- Add the agreed citation guidance to the canonical installed agent guide. +- Verify that both Codex and Claude receive the guidance. + +## Out Of Scope + +- Changing instruction installation mechanics or destinations. +- Changing Almanac page citation syntax or wiki authoring rules. + +## Files + +- Modify: `src/codealmanac/services/setup/agent-guide.md` +- Modify: `tests/test_setup_service.py` + +## Verification + +```bash +uv run pytest tests/test_setup_service.py -k 'codex_block or claude_guide' +uv run ruff check tests/test_setup_service.py +``` diff --git a/docs/plans/2026-07-16-cli-telemetry-follow-up-review-fixes.md b/docs/plans/2026-07-16-cli-telemetry-follow-up-review-fixes.md new file mode 100644 index 00000000..9f5ae250 --- /dev/null +++ b/docs/plans/2026-07-16-cli-telemetry-follow-up-review-fixes.md @@ -0,0 +1,71 @@ +# CLI Telemetry Follow-up Review Fixes Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Close the two validated follow-up findings without leaking run specs into public results or retaining heuristic failure classification. + +**Architecture:** Durable run transitions stay authoritative even if every telemetry-only step fails. Operation failure categories come from the explicit workflow phase that failed, with one typed harness-unavailable error distinguishing readiness from provider execution and a new `source_preparation` category for ingest source resolution/runtime. + +**Tech Stack:** Python 3.12+, Pydantic models, SQLite run ledger, pytest, Ruff, uv. + +--- + +## Review verdicts + +1. **Accept the P1 behavior, reject adding `RunSpec` to transition results.** The reproduced `read_spec` failure makes `RunsService.finish` raise after the durable commit. Returning the source-bearing spec through cancellation result models risks widening public JSON and couples product transitions to telemetry. A RunsService helper will instead wrap the complete telemetry-only read-and-capture path. +2. **Accept the P2 behavior, reject more exception/traceback guessing.** `ExecutionFailed` spans readiness and provider execution; `ValidationFailed` spans source input and final wiki validation. The workflow already knows the active phase, so it will persist that phase explicitly. This follows the service-layer role of “orchestrating our workflows” in `docs/reference/cosmic-python/chapter_04_service_layer.md` and keeps the durable commit authoritative as described in `docs/reference/cosmic-python/chapter_06_uow.md`. + +### Task 1: Make terminal telemetry completely best effort + +**Files:** +- Modify: `src/codealmanac/services/runs/service.py` +- Modify: `src/codealmanac/services/telemetry/service.py` +- Test: `tests/test_run_telemetry.py` + +1. Add failing finish, queued-cancellation, and running-cancellation tests that make the telemetry-only `read_spec` call raise after the store transition. +2. Assert each service call returns normally and the durable terminal status remains visible. +3. Add one RunsService helper that catches both `store.read_spec(...)` and `telemetry.capture_lifecycle(...)` failures. +4. Route all three terminal capture sites through the helper. +5. Wrap lifecycle event shaping inside TelemetryService's own best-effort boundary so direct callers cannot observe shaping failures. +6. Run `uv run pytest tests/test_run_telemetry.py -q`. + +### Task 2: Replace heuristic failure classification with explicit phases + +**Files:** +- Modify: `src/codealmanac/services/harnesses/service.py` +- Modify: `src/codealmanac/services/runs/models.py` +- Modify: `src/codealmanac/services/telemetry/models.py` +- Modify: `src/codealmanac/workflows/operations/service.py` +- Modify: `src/codealmanac/workflows/build/service.py` +- Modify: `src/codealmanac/workflows/ingest/service.py` +- Modify: `src/codealmanac/workflows/garden/service.py` +- Test: `tests/test_harnesses_service.py` +- Test: `tests/test_build_workflow.py` +- Test: `tests/test_ingest_workflow.py` +- Test: `tests/test_garden_workflow.py` + +1. Add a failing unavailable-harness workflow test that expects persisted `harness_readiness`. +2. Add a failing invalid-ingest-source test that expects persisted `source_preparation`. +3. Add `HarnessUnavailable` as a typed `ExecutionFailed` subtype emitted only by failed readiness checks. +4. Add `SOURCE_PREPARATION = "source_preparation"` to the durable category enum and typed telemetry allowlist. +5. Make `OperationRunner.fail(...)` require an explicit category. +6. Add `OperationRunner.failure_phase(...)`, which records the supplied category and re-raises the original error. +7. Make shared execution own its phase boundaries: readiness/provider execution, run-event persistence, indexing, final wiki validation, and terminal completion. +8. Make build, ingest, and garden wrap only their operation-specific preparation phases; do not catch and re-fail errors already owned by `OperationRunner.execute`. +9. Delete `operation_failure_category(...)` and traceback-module inference. +10. Run the focused harness and workflow tests. + +### Task 3: Update living architecture and verify PR #36 + +**Files:** +- Modify: `almanac/architecture/telemetry.md` +- Modify: `almanac/architecture/lifecycle/operation-runner.md` +- Modify: `almanac/architecture/lifecycle/workflows.md` +- Modify: `almanac/concepts/run-ledger.md` +- Modify: `docs/plans/2026-07-15-cli-telemetry-live-agreement.md` + +1. Document the complete best-effort telemetry boundary and explicit failure-phase ownership. +2. Update the PR description's test count if it still says 534. +3. Run `uv run pytest`, `uv run ruff check .`, `uv run codealmanac validate`, and `git diff --check`. +4. Build and install the wheel in an isolated environment and run version/config smokes. +5. Commit and push only the planned files, then wait for all PR checks and confirm mergeability. diff --git a/docs/plans/2026-07-16-cli-telemetry-review-fixes.md b/docs/plans/2026-07-16-cli-telemetry-review-fixes.md new file mode 100644 index 00000000..4443d802 --- /dev/null +++ b/docs/plans/2026-07-16-cli-telemetry-review-fixes.md @@ -0,0 +1,125 @@ +# CLI Telemetry Review Fixes Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Close the five validated PR #36 review findings without weakening consent, privacy, run-ledger honesty, or existing CLI behavior. + +**Architecture:** The run ledger will own durable failure classification and expose whether a terminal transition actually changed state. Telemetry will react only to that first transition. Exception events will contain structural data only, and the PostHog adapter will rebuild outbound properties from the exact validated event keys instead of subtracting known SDK fields. + +**Tech Stack:** Python 3.12+, Pydantic models, SQLite run ledger, PostHog Python SDK, pytest, Ruff, uv. + +--- + +## Accepted shape + +```python +# arch: workflows classify · ledger persists · transition reports change +result = runs.store.finish(..., failure_category=category) +if result.changed: + telemetry.capture_lifecycle(result.record, spec) +return result.record + +# arch: exception telemetry is structural, never free-form +shape = exception_type + fingerprint + codealmanac_frames + +# arch: validated service keys are the entire outbound property surface +allowed = frozenset(event.properties) +posthog.before_send = keep_only(message.properties, allowed) +``` + +The existing classification sites remain authoritative. Telemetry does not inspect local error prose to guess a category. + +### Task 1: Integrate current main cleanly + +**Files:** +- Modify: `pyproject.toml` +- Regenerate: `uv.lock` + +1. Merge `origin/main` into `codex/cli-telemetry`. +2. Preserve both the main-branch `filelock` dependency and the telemetry branch `posthog>=7,<8` dependency. +3. Regenerate `uv.lock` with `uv lock`; do not hand-edit generated package entries. +4. Run `uv sync --locked` and the focused setup/update tests changed by main. + +### Task 2: Make exception capture structural and non-throwing + +**Files:** +- Modify: `src/codealmanac/services/telemetry/service.py` +- Modify: `src/codealmanac/cli/execution.py` +- Modify: `src/codealmanac/workflows/run_queue/worker.py` +- Test: `tests/test_telemetry_service.py` +- Test: `tests/test_cli_telemetry.py` +- Test: `tests/test_run_telemetry.py` + +1. Add failing tests proving arbitrary relative names, prompts, provider output, and repository text never enter `$exception_message` or `$exception_list`. +2. Add a failing CLI-boundary test whose exception `__str__` raises; the original exception must still be re-raised and a structural event must be captured. +3. Replace free-form exception values with the exception type. Keep the stable fingerprint and bounded CodeAlmanac-only frames. +4. Wrap all exception shaping inside the telemetry best-effort boundary so it cannot replace the product exception. +5. Remove now-unused path/value redaction inputs and helpers from callers. +6. Run the focused telemetry and CLI telemetry tests. + +### Task 3: Enforce an outbound property allowlist after the SDK + +**Files:** +- Modify: `src/codealmanac/integrations/telemetry/sender.py` +- Test: `tests/test_telemetry_sender.py` + +1. Add a failing test that injects an unknown future SDK property in `before_send` and expects it to be removed. +2. Build the hook from `frozenset(event.properties)` after `TelemetryEvent` validation. +3. Replace the denylist with a function that reconstructs `message["properties"]` from only those allowed keys. +4. Verify command and exception event properties survive unchanged while arbitrary SDK context does not. + +### Task 4: Persist failure category and expose terminal transition change + +**Files:** +- Modify: `src/codealmanac/services/runs/models.py` +- Modify: `src/codealmanac/services/runs/requests.py` +- Modify: `src/codealmanac/services/runs/transitions.py` +- Modify: `src/codealmanac/services/runs/store.py` +- Modify: `src/codealmanac/services/runs/service.py` +- Modify: `src/codealmanac/services/telemetry/service.py` +- Test: `tests/test_runs_service.py` +- Test: `tests/test_run_telemetry.py` + +1. Add failing tests that a failed run persists its controlled category and a second finish cannot rewrite a terminal record or append another terminal status event. +2. Add `failure_category` to `RunRecord`; preserve compatibility with older failed records that lack it. +3. Require new failed `FinishRunRequest` values to carry a category and reject categories for non-failed statuses. +4. Add `RunFinishResult(record, changed)` beside the existing cancellation results. +5. Make `finish_run` return `changed=False` for every already-terminal record. +6. Have `RunStore.finish` persist the category and return the neutral result. +7. Have `RunsService` capture lifecycle telemetry only when finish/cancellation actually creates the terminal transition. +8. Make telemetry read `record.failure_category`; keep `internal_error` only as a legacy-record fallback. + +### Task 5: Prove opt-out cannot be replayed after re-enable + +**Files:** +- Test: `tests/test_run_telemetry.py` + +1. Add a failing finish test: opt out, finish, re-enable, retry finish, and assert no event. +2. Add the same sequence for queued cancellation. +3. Add the same sequence for confirmed running cancellation. +4. Run the focused run and telemetry tests after transition gating is implemented. + +### Task 6: Classify returned exit code 130 as interrupted + +**Files:** +- Modify: `src/codealmanac/cli/execution.py` +- Test: `tests/test_cli_telemetry.py` + +1. Add a failing test through the real `jobs attach` dispatch path where the handler catches Ctrl-C and returns `130`. +2. Map returned `130` to telemetry outcome `interrupted`; keep other nonzero codes as `failed`. +3. Assert the event keeps command `jobs`, action `attach`, and exit code `130`. + +### Task 7: Update architecture knowledge and verify the PR + +**Files:** +- Modify: `almanac/architecture/telemetry.md` +- Modify: `almanac/concepts/run-ledger.md` +- Modify: `almanac/reference/runs/run-states-and-events.md` +- Modify: `docs/plans/2026-07-15-cli-telemetry-live-agreement.md` + +1. Document structural exception events, exact outbound allowlisting, persisted failure categories, and first-transition-only capture. +2. Run focused red/green tests for each finding. +3. Run `uv run pytest`, `uv run ruff check .`, `uv run codealmanac validate`, `git diff --check`, and `uv build`. +4. Build and install the final wheel in an isolated environment and smoke import/version/help. +5. Review the full `origin/main...HEAD` diff for privacy and unrelated-file leakage. +6. Commit the review fixes, push PR #36, and wait for GitHub CI. diff --git a/docs/plans/2026-07-16-cli-telemetry-third-review-fixes.md b/docs/plans/2026-07-16-cli-telemetry-third-review-fixes.md new file mode 100644 index 00000000..d47b8bef --- /dev/null +++ b/docs/plans/2026-07-16-cli-telemetry-third-review-fixes.md @@ -0,0 +1,74 @@ +# CLI Telemetry Third Review Fixes Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Close the three reproduced privacy, terminal-transition, and provider-classification findings without coupling durable run specs to a changing model catalog. + +**Architecture:** The typed telemetry boundary validates the harness/model pair against the central controlled catalog and drops unknown values. Operation failure recording treats the failed transition as authoritative and the readable error event as independent best effort. Harness readiness and provider invocation become separate service calls, with event-sink failures typed so internal persistence errors cannot be mistaken for provider failures. + +**Tech Stack:** Python 3.12+, Pydantic models, SQLite run ledger, pytest, Ruff, uv. + +--- + +## Review verdicts + +1. **Accept the privacy bug; reject catalog validation in durable `RunSpec`.** Historical queued specs must remain readable when the controlled catalog changes. `LifecycleRunCompletedProperties` will validate the harness/model pair against `HARNESS_MODELS`, causing `capture_lifecycle` to fail closed before event construction. +2. **Accept the authoritative-transition bug.** A readable error event and the terminal run write are separate effects. A safe shared error summary will be computed once; error-event persistence and `runs.finish(...)` will each receive their own best-effort boundary. +3. **Accept the provider-classification bug and split the contract.** `HarnessesService.ensure_ready(...)` owns readiness; `run_ready(...)` invokes the already-approved adapter. A typed sink wrapper distinguishes caller event-persistence failures from adapter failures occurring during the same call. + +### Task 1: Reject unknown lifecycle models at the telemetry boundary + +**Files:** +- Modify: `src/codealmanac/services/telemetry/models.py` +- Test: `tests/test_run_telemetry.py` +- Test: `tests/test_telemetry_service.py` + +1. Add a failing lifecycle test whose valid queued `RunSpec` contains path/secret-like model text. +2. Complete the run and assert the recording sender receives no lifecycle event. +3. Add a model-level mismatch test using a controlled model with the wrong harness. +4. Validate `LifecycleRunCompletedProperties.model` against `HARNESS_MODELS[HarnessKind(harness)]` in the existing model validator. +5. Run the focused telemetry tests and confirm both pass. + +### Task 2: Preserve the authoritative failed transition when event logging fails + +**Files:** +- Modify: `src/codealmanac/core/errors.py` +- Modify: `src/codealmanac/workflows/operations/service.py` +- Test: `tests/test_ingest_workflow.py` + +1. Add a failing test that begins an operation, makes `OperationRunner.record(...)` raise, calls `fail(..., SOURCE_PREPARATION)`, and expects a durable failed record with the original category. +2. Add a failing test whose exception `__str__` raises and expect the same durable failed transition with the exception type as its safe message. +3. Make `error_summary(...)` return the exception type when stringification fails. +4. Replace the shared suppression block in `OperationRunner.fail(...)` with one block for the readable error event and a separate block for `runs.finish(...)`. +5. Run the focused workflow tests. + +### Task 3: Separate readiness, provider invocation, and event-sink failure + +**Files:** +- Modify: `src/codealmanac/services/harnesses/service.py` +- Modify: `src/codealmanac/workflows/operations/service.py` +- Test: `tests/test_harnesses_service.py` +- Test: `tests/test_build_workflow.py` + +1. Add a failing build workflow test whose adapter is ready but raises during `run`; expect `provider_execution`. +2. Add a failing test whose live event sink raises; expect `internal_error`, not provider execution. +3. Add `HarnessEventSinkFailed` carrying the original sink exception. +4. Keep `ensure_ready(...)` as the readiness verb and replace combined `run(...)` with `run_ready(...)`, which calls only the selected adapter and wraps sink failures. +5. In `OperationRunner.execute(...)`, run readiness and provider invocation in separate try/except blocks. Map readiness exceptions to `harness_readiness`, sink exceptions to `internal_error`, and adapter exceptions to `provider_execution`. +6. Preserve normalized failed-result validation as `provider_execution`. +7. Run focused harness, build, ingest, and garden tests. + +### Task 4: Update living architecture and verify PR #36 + +**Files:** +- Modify: `almanac/architecture/telemetry.md` +- Modify: `almanac/architecture/agent-runs/harness-contract.md` +- Modify: `almanac/architecture/lifecycle/operation-runner.md` +- Modify: `docs/python-port-live-agreement.md` +- Modify: `docs/plans/2026-07-15-cli-telemetry-live-agreement.md` + +1. Document controlled model fail-closed behavior, independent failure writes, and the split harness contract. +2. Run `uv run pytest`, `uv run ruff check .`, `uv run codealmanac validate`, and `git diff --check`. +3. Run the full suite on Python 3.13. +4. Build/install the wheel and smoke version/config/model validation with telemetry disabled. +5. Update the PR validation count, commit only planned files, push, and wait for all GitHub checks. diff --git a/docs/plans/2026-07-16-serve-opens-viewer.md b/docs/plans/2026-07-16-serve-opens-viewer.md new file mode 100644 index 00000000..59872757 --- /dev/null +++ b/docs/plans/2026-07-16-serve-opens-viewer.md @@ -0,0 +1,56 @@ +# Serve Opens The Viewer + +**Goal:** Make `codealmanac serve` open the local viewer in the default browser +once the HTTP server is ready, while preserving the printed URL and allowing +headless callers to opt out. + +## Scope + +- Open the viewer by default after Uvicorn has bound its listening socket. +- Add `--no-open` for SSH, containers, and scripted usage. +- Preserve the existing host, port, wiki selection, URL output, server logs, + clean Ctrl-C shutdown, and startup-failure exit behavior. +- Document and test both default and opt-out behavior. + +## Design + +Browser launching remains in `cli/dispatch/serve.py`, beside the Uvicorn process +mechanism it accompanies. The command creates a normal `uvicorn.Server` and a +small daemon thread waits for `Server.started` before using Python's standard +`webbrowser` module. This avoids opening a browser for a server that failed to +bind and keeps the read-only viewer application free of desktop side effects. + +```python +server = uvicorn.Server(config) +browser.open_when_ready(server, url) # skipped by --no-open +server.run() +``` + +No service or application-layer seam is needed: opening a desktop browser is a +single CLI adapter concern, not viewer product logic. + +## Files + +- `src/codealmanac/cli/parser/wiki.py`: add `--no-open`. +- `src/codealmanac/cli/dispatch/serve.py`: launch the browser after startup. +- `src/codealmanac/cli/syntax/catalog.py`: expose the opt-out in guidance. +- `tests/test_cli.py`: cover parser and runtime behavior. +- `README.md`: describe the new default and opt-out. +- `almanac/architecture/viewer/local-viewer.md` and + `almanac/reference/cli/public-command-surface.md`: update durable command + knowledge. + +## Verification + +- Focused CLI tests for default opening and `--no-open`. +- `uv run pytest`. +- `uv run ruff check .`. + +## Read Before Coding + +- `MANUAL.md` +- `docs/python-port-live-agreement.md` +- `almanac/architecture/viewer/local-viewer.md` +- `almanac/architecture/cli/adapter-boundary.md` +- `almanac/architecture/cli/terminal-output.md` +- `almanac/reference/cli/public-command-surface.md` diff --git a/docs/plans/2026-07-16-viewer-first-job-receipt.md b/docs/plans/2026-07-16-viewer-first-job-receipt.md new file mode 100644 index 00000000..157a1235 --- /dev/null +++ b/docs/plans/2026-07-16-viewer-first-job-receipt.md @@ -0,0 +1,63 @@ +# Viewer-First Job Receipt + +**Goal:** Make lifecycle queue output clearly teach users how to follow work in +the local viewer's Jobs section instead of burying that action among metadata. + +## Scope + +- Redesign the human receipt shared by `init`, `ingest`, and `garden`. +- Give each operation a plain-language background-work headline. +- Put `codealmanac serve` and “Jobs in the sidebar” together inside a large, + blue bordered next-action box. +- Keep `codealmanac jobs attach <run-id>` as the terminal alternative. +- Move the job id and repository name to quiet metadata at the bottom. +- Preserve the existing `--json` receipt exactly, including `runs_ahead` and + `child_pid`. + +## Output Shape + +```text +◆ Building your CodeAlmanac in the background + + ╭──────────────────────────────────────────────────────────────╮ + │ │ + │ Follow progress in the CodeAlmanac viewer │ + │ │ + │ codealmanac serve │ + │ │ + │ Then select Jobs in the sidebar. │ + │ │ + ╰──────────────────────────────────────────────────────────────╯ + + Prefer the terminal? + codealmanac jobs attach <run-id> + + Job: <run-id> · Repo: <name> +``` + +Ingest uses “Adding knowledge to your CodeAlmanac…” and Garden uses “Improving +your CodeAlmanac…”. The shared viewer and terminal instructions remain +identical. + +## Files + +- `src/codealmanac/cli/render/run_commands.py`: replace the metadata-first + receipt and operation quips with the boxed viewer-first hierarchy. +- `tests/test_cli.py`: assert the three operation headlines, viewer guidance, + terminal command, and quiet metadata. +- `README.md`: describe both viewer and terminal job-following paths. +- `almanac/architecture/cli/terminal-output.md`: preserve the output rationale. + +## Verification + +- Focused lifecycle CLI tests. +- `uv run pytest`. +- `uv run ruff check .`. +- `codealmanac validate`. + +## Read Before Coding + +- `MANUAL.md` +- `almanac/architecture/cli/terminal-output.md` +- `almanac/reference/cli/json-output-contract.md` +- `almanac/concepts/run-ledger.md` diff --git a/docs/plans/fixes-slice-141-review.md b/docs/plans/fixes-slice-141-review.md new file mode 100644 index 00000000..8956aba2 --- /dev/null +++ b/docs/plans/fixes-slice-141-review.md @@ -0,0 +1,48 @@ +# Fixes: slice 141 section-search review + +## Review outcome + +The section projection, versioned rebuild, FTS query analyzer, and typed result +evidence are correctly placed and the full suite passes. Real-wiki searches put +the expected cancellation and path-normalization pages first. The review found +no must-fix correctness defects and two should-fix gaps before release. + +## Should fix + +### Collapse and limit pages in SQLite + +The implementation orders every matching section, returns all rows to Python, +then removes duplicate pages and applies the user limit. A page with many +matching sections therefore inflates transfer and Python work even when the +caller asks for a small result set. Rank sections per page in SQL, retain the +best section, apply the final page ordering and limit there, and remove the now +redundant Python collapse. + +Add a regression proving that a limit counts unique pages rather than matching +sections and still retains the best evidence for each page. + +### Verify the new public evidence and weighting contracts + +The implementation carries `matched_heading` and `excerpt` into the viewer +DTO, and the ranking weights prefer titles and headings over body-only matches, +but the tests stop at the index service. Extend existing viewer/server tests to +assert the additive evidence fields and add a focused ranking test. These are +public behavior boundaries and should not be left implicit. + +## Consider + +- The excerpt is a bounded slice of authored Markdown rather than fully + rendered plain text. Keep this honest source excerpt for now: stripping + Markdown would add a second content representation without evidence that the + visible punctuation harms consumers. +- Common question words remain eligible OR terms by design. The real-wiki + probes ranked the intended pages first; add stop-word policy only if measured + retrieval failures justify its language and identifier trade-offs. + +## Verification + +- Focused read-model, viewer, server, and query tests. +- Synthetic projection/search timing with many pages and sections. +- `uv run pytest`. +- `uv run ruff check .`. + diff --git a/docs/plans/slice-141-section-search.md b/docs/plans/slice-141-section-search.md new file mode 100644 index 00000000..e6e933f4 --- /dev/null +++ b/docs/plans/slice-141-section-search.md @@ -0,0 +1,140 @@ +# Slice 141: section-level lexical search + +**Status:** implementation plan +**Depends on:** `docs/plans/2026-07-14-section-search-design.md` + +## Outcome + +Replace whole-page strict-AND search with a rebuildable section projection and +recall-oriented SQLite FTS5/BM25 search. The user still searches for pages and +opens a page with `show`; each text-search result additionally carries the +heading and excerpt that explain why the page ranked. + +## Scope + +- Parse authored Markdown into deterministic heading-based sections with the + existing `markdown-it-py` dependency. +- Persist section rows and an FTS5 index as derived state. +- Turn natural-language queries into escaped prefix terms joined with `OR`. +- Rank section matches with weighted SQLite `bm25()`, preferring page title, + then heading, then body. +- Collapse multiple section matches to one page using the best section score + and deterministic tie-breaking. +- Preserve topic and mention filters, no-query ordering, page identity, + implicit refresh, and the `search -> show` interaction. +- Add match heading and excerpt to the typed search result and expose them + consistently through terminal JSON and viewer DTOs. Human terminal output + remains byte-for-byte compatible in this slice. + +## Out of scope + +- Embeddings, semantic or hybrid retrieval, query rewriting, fuzzy matching, + an external search engine, user-controlled ranking weights, or iterative + retrieval. +- Changing authored Markdown, page routes, topics, mentions, or `show`. +- Stop-word dictionaries or English-only stemming. Recall comes from OR + eligibility; BM25 naturally pushes weak common-term matches down. + +## Frozen search policy + +### Section projection + +`wiki.sections.project(body)` owns Markdown meaning. A heading opens a section +that extends until the next heading of any level. Its `heading_path` contains +the active heading ancestry. Text before the first heading is an introduction +section using the page title as its display heading. Empty heading sections are +kept because the heading itself is searchable and may name a concept. + +Section order is source order. `section_id` is the zero-padded source ordinal; +it is deterministic for identical Markdown across rebuilds and unique within a +page. The stored body is the exact Markdown source slice after the opening +heading and before the next heading. FTS indexes the page slug, page title, +heading path, and section body; authored Markdown remains the source of truth. + +### Query analysis + +The analyzer extracts Unicode word tokens, case-folds them, preserves their +first-seen order, removes duplicates, quotes every token as FTS syntax data, +adds prefix matching, and joins terms with `OR`. No raw FTS operator reaches +SQLite. Punctuation-only queries behave as empty text queries so topic and +mention filters remain usable. + +The first implementation deliberately has no stop-word list. A fixed English +list would discard valid identifiers and names, while OR matching already +prevents filler words from excluding the correct section. The benchmark and +real-repo probes will determine whether a later language-aware analyzer earns +its cost. + +### Ranking and collapse + +Use `bm25(fts_sections, 0.0, 5.0, 3.0, 1.0)` for slug, page title, heading, +and body. Slug is candidate material but has zero score weight because title +and heading are the intended authored relevance signals. Sort section matches +by ascending BM25 score, then page slug and section ordinal. A grouped query +retains the minimum score per page and its first section under that ordering. +Final pages sort by best score, newest page timestamp, then slug. `snippet()` +produces a bounded plain-text excerpt from the matched section body; if the +body has no snippet, the heading is the only match evidence. + +## Ownership and file changes + +- `services/wiki/sections.py`: typed `WikiSection` projection from Markdown. +- `services/index/schema.py`: versioned `page_sections` and `fts_sections` + derived schema; retire `fts_pages`. +- `services/index/projection.py`: persist each document and its section rows. +- `services/index/query.py`: safe lexical query analysis. +- `services/index/search_views.py`: SQL filtering, weighted ranking, collapse, + row conversion, and existing mention composition. +- `services/index/models.py`: additive `matched_heading` and `excerpt` fields. +- `services/viewer/models.py` and `services/viewer/projections.py`: carry the + same optional evidence through the browser API. +- CLI rendering remains unchanged except JSON automatically gains the additive + typed fields. + +This follows the local repository rule that a store hides persistence detail +and the service layer remains the stable product entrypoint: "The service +layer will become the main way into our app" +(`docs/reference/cosmic-python/chapter_04_service_layer.md`). + +## Test coverage + +Write failing tests first for: + +- deterministic heading paths, source slices, duplicate headings, preamble, + code blocks containing `#`, setext headings, and heading-only sections; +- safe query escaping, prefix matching, duplicate terms, Unicode identifiers, + and punctuation-only input; +- natural questions that strict AND previously rejected; +- title and heading weighting over body-only matches; +- multiple matching sections collapsing to one page with stable evidence; +- topic and mention filters composed with text search; +- identifiers, dates, paths, and exact error strings; +- stale-schema rebuild and deterministic repeated projection; +- service, JSON, viewer, and unchanged human terminal behavior. + +Run targeted read-model/wiki/viewer/CLI tests during construction. Before the +implementation commit, run `uv run pytest` and `uv run ruff check .`. There is +no configured Pyright gate in `pyproject.toml`. + +## Dogfood and performance + +Search the real CodeAlmanac wiki with at least: + +- `How does cancellation terminate a running agent?` +- `Where is path normalization handled?` +- exact identifiers and paths already used by agents. + +Record result ordering and wall time. Build a temporary synthetic wiki large +enough to detect accidental per-result SQL or obviously nonlinear projection; +do not add benchmark machinery to the product repository. + +## Review and commit gates + +1. Commit this implementation plan. +2. Implement section projection and persistence with focused tests. +3. Implement query/ranking/result evidence with service and edge tests. +4. Run dogfood and full gates; commit the coherent implementation. +5. Review using `.claude/agents/review.md`, write + `docs/plans/fixes-slice-141-review.md`, fix must/should findings, rerun full + gates, and commit review fixes separately. + diff --git a/docs/plans/slice-142-atomic-worker-idle-handoff.md b/docs/plans/slice-142-atomic-worker-idle-handoff.md new file mode 100644 index 00000000..683165c5 --- /dev/null +++ b/docs/plans/slice-142-atomic-worker-idle-handoff.md @@ -0,0 +1,102 @@ +# Slice 142 — Atomic Worker Idle Handoff + +**Date:** 2026-07-11 + +## Goal + +Prevent durable queued runs from being stranded when they arrive as the active +queue worker is deciding that the queue is empty and releasing its global lock. + +## Problem Today + +The worker performs these persistence operations separately: + +```python +queued = runs.next_queued() +if queued is None: + break +finally: + lease.release() +``` + +A producer can insert a queued run after `next_queued()` returns `None`, spawn a +second worker that loses the still-held lock, and then watch the first worker +release the lock and exit. The queued run has no remaining wakeup. + +## Architecture Fit + +This is one atomic persistence operation, not retry machinery and not a new +worker lifecycle. The run service exposes the product verb, the run store +delegates persistence, and the worker-lock store owns the transaction that +rechecks queue membership and changes lock ownership. + +The Unit of Work reference describes the property needed here as “A stable +snapshot of the database” and “a way to persist all of our changes at once” +[`chapter_06_uow.md`](../reference/cosmic-python/chapter_06_uow.md). + +```python +queued = runs.next_queued() +if queued is None: + outcome = runs.release_worker_if_idle(lease.owner) + if outcome is WORK_AVAILABLE: + continue + break +``` + +The repository boundary keeps SQLite transaction details out of the workflow. +The repository reference describes its purpose as “hiding the complexities of +the database” +[`chapter_02_repository.md`](../reference/cosmic-python/chapter_02_repository.md). + +## Scope + +### In scope + +- Add a typed idle-handoff outcome. +- Add a service request carrying the current lock owner. +- In one `BEGIN IMMEDIATE` transaction: + - verify that the caller still owns the worker lock; + - recheck the exact durable queue-membership predicate; + - keep the lock when work exists, or delete it when the queue is empty. +- Continue draining if work appeared during the old empty-to-release window. +- Make worker lock check-and-claim explicitly transactional too. +- Add persistence and workflow regression tests. +- Update the run-queue Almanac page after verification. + +### Out of scope + +- Persistent daemon workers. +- Polling, sleeps, retries, or a notification channel. +- Multiple concurrent queue consumers. +- Changing FIFO queue selection. +- Changing explicit `max_runs` behavior; a bounded drain releases normally + when its requested limit is reached. + +## Transaction Ordering + +Both run insertion and idle handoff use `BEGIN IMMEDIATE` on the same SQLite +database. + +```text +handoff transaction first + -> confirms empty and deletes lock + -> producer inserts run and its spawned worker acquires the free lock + +producer transaction first + -> inserts run + -> handoff sees work and retains the lock + -> active worker continues draining +``` + +There is no committed state in which a producer has queued work, its spawned +worker has lost the lock, and the lock owner has already committed an empty +handoff. + +## Tests + +- Idle handoff releases the lock when no queued spec exists. +- Idle handoff retains the lock and reports work when a queued spec exists. +- A queued run inserted at the worker's empty-handoff seam is processed by the + current drain rather than stranded. +- A bounded `max_runs` drain still stops after its requested count. +- Run focused tests, full pytest, Ruff, and the architecture suite. diff --git a/docs/python-port-live-agreement.md b/docs/python-port-live-agreement.md index ab3d3171..6f771e69 100644 --- a/docs/python-port-live-agreement.md +++ b/docs/python-port-live-agreement.md @@ -5,6 +5,23 @@ It is the constraint document for future agents. ## Current Decisions +- 2026-07-15: Anonymous product telemetry is the only remote product exception + in Python v1. Setup asks last, defaults to the recommended Yes, keeps a visible + No, and `--no-telemetry`, `telemetry.enabled = false`, `DO_NOT_TRACK`, or + `CODEALMANAC_NO_TELEMETRY` disable all capture. A random installation UUID in + local SQLite identifies events and may have a UUID-only PostHog person + profile; no name or email exists until a future login explicitly links an + opaque account identity. GeoIP is disabled. Allowlisted command and lifecycle + outcomes may leave the machine, and real unhandled foreground/background + Python exceptions may send only their type, stable structural fingerprint, + and CodeAlmanac-only stack shape. Code, exception messages, paths, args, + queries, repository/run identifiers, prompts, + transcripts, Git data, provider session IDs, locals, environment variables, + and code variables never leave the machine. +- 2026-07-16: Lifecycle telemetry exports a model only when its harness/model + pair matches the central controlled catalog; unknown or incompatible values + drop the event. Durable `RunSpec` remains readable independently of that + changing outbound catalog so historical queued records do not become invalid. - 2026-06-29: Python v1 is a local product. Do not build hosted shipping, hosted CLI, login/connect/upload, SDK, or MCP in this rewrite. - 2026-07-05: `e773dc0b` is the fork point for the right local Python product. @@ -149,11 +166,13 @@ It is the constraint document for future agents. partial-uninstall flags. The command removes CodeAlmanac-owned instructions, automation, global state, and the installed binary when the install method supports removal. It never deletes repo `almanac/`. -- 2026-06-29: There is no cloud capture surface in Python v1. +- 2026-06-29 (superseded 2026-07-15): There was no cloud capture surface in + Python v1 before the narrowly agreed anonymous product-telemetry exception. `codealmanac update` updates the installed CLI package only. `sync` scans local transcripts and runs local ingest. `automation` schedules local `sync`/`garden`/`update`. Do not add public `capture`, cloud upload, hosted - connection, login, or remote collection commands without a new agreement. + connection or login commands without a new agreement. The telemetry exception + adds no upload/capture command and never transmits wiki or transcript content. - 2026-06-29: `sync` is a scanner, not a replay ledger. It reads `sync_state.last_completed_at`, finds active transcript files, queues ingest runs, records the scan as complete, and relies on visible jobs for individual @@ -191,6 +210,10 @@ It is the constraint document for future agents. it is not a public command and it does not contain lifecycle business logic. Do not add parallel foreground/background modes; `jobs attach <run-id>` is the public way to follow a run. +- 2026-07-11: Worker shutdown uses an atomic idle handoff. The lock owner + rechecks durable queue membership and deletes the global worker lock in one + immediate SQLite transaction. If work exists it keeps ownership and resumes + draining; do not replace this with polling, sleeps, or a permanent daemon. - 2026-07-01: `sync` queues eligible transcript ingests and starts the same worker path as manual lifecycle commands. Scheduled automation also launches ordinary scanner/queue commands rather than a separate sync worker model. @@ -201,6 +224,18 @@ It is the constraint document for future agents. recording, mutation validation, index refresh, terminal success, and failure recording to the page-run workflow. Do not move this harness/run plumbing back into individual operation workflows. +- 2026-07-16: Durable operation failure categories come from explicit workflow + phases, never broad exception classes or traceback-module inference. + `OperationRunner` owns shared readiness, provider, indexing, wiki-validation, + and internal phases; operation workflows name only their preparation phases, + such as ingest `source_preparation`, while delegating the actual failure write + to the runner. +- 2026-07-16: Harness readiness and provider invocation are separate operation + stages. After readiness succeeds, adapter exceptions are + `provider_execution` regardless of exception class; caller event-sink errors + remain `internal_error`. Failure-event persistence and the authoritative + terminal transition are independent best-effort effects, so one cannot skip + the other. - 2026-07-01: Shared lifecycle helper responsibilities are split behind the import-compatible `workflows/lifecycle.py` facade. `lifecycle_mutation.py` owns Git/workspace mutation preflight, reported-change validation, path-diff @@ -486,6 +521,17 @@ It is the constraint document for future agents. and index connection helper; `sources.py` owns page/topic source loading and freshness signatures; `projection.py` owns replacement writes and stored source signatures. `store.py` stays the service-facing facade. +- 2026-07-14: Search moves from whole-page strict-AND FTS to section-level + SQLite FTS5 with BM25 ranking. Markdown headings define coherent indexed + sections; a recall-oriented lexical query admits sections matching meaningful + terms; page title and section heading receive more weight than body prose; + and results collapse back to ranked pages while retaining the best matching + heading and excerpt. The public product remains `search -> show`, Markdown + remains truth, and SQLite remains rebuildable derived state. Do not add + semantic retrieval yet. If measured real-repo and benchmark misses repeatedly + show relevant sections with insufficient lexical overlap, add dense retrieval + as a second candidate generator and fuse it with lexical results rather than + replacing exact lexical search. - 2026-07-10: `config` owns the only configuration file at `~/.codealmanac/config.toml`; repository-level `almanac/config.toml` is removed. The user config stores auto-commit, harness/model, and enabled/ @@ -930,7 +976,7 @@ subprocess.run(["codealmanac", "show", "..."]) | MCP | No MCP server | | Compatibility aliases | No public `almanac`, `alm`, or `absorb` | | Archive lineage | No `archived_at` / `superseded_by` page state | -| Semantic search | FTS and refs first | +| Semantic search | Section-level FTS5/BM25 first; hybrid only after measured lexical-overlap misses | ## Non-Negotiables diff --git a/pyproject.toml b/pyproject.toml index c0317cd2..087f6f41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codealmanac" -version = "0.4.4" +version = "0.4.7" description = "A local codebase wiki maintained by AI coding agents." readme = "README.md" requires-python = ">=3.12" @@ -23,15 +23,17 @@ classifiers = [ "Topic :: Software Development :: Documentation", ] dependencies = [ - "almanac-yoke[claude]>=0.1.7,<0.2", + "almanac-yoke[claude]>=0.1.7,<0.2", "beautifulsoup4>=4.15.0", "charset-normalizer>=3.4.7", "fastapi>=0.138.1", + "filelock>=3.12.0", "httpx>=0.28.1", "humanfriendly>=10.0", "jsonlines>=4.0", "markdown-it-py>=4.2.0", "pathspec>=1.1.1", + "posthog>=7,<8", "psutil>=7.2.2", "pydantic>=2.8", "pydantic-settings>=2.12.0", @@ -42,6 +44,7 @@ dependencies = [ ] [project.scripts] +ca = "codealmanac.cli.main:main" codealmanac = "codealmanac.cli.main:main" [project.urls] diff --git a/src/codealmanac/agents/build/instructions.md b/src/codealmanac/agents/build/instructions.md index 6cc2bc4c..598375fd 100644 --- a/src/codealmanac/agents/build/instructions.md +++ b/src/codealmanac/agents/build/instructions.md @@ -60,6 +60,10 @@ define how the project works: docs, entrypoints, command or API surfaces, domain modules, workflows, persistence, integrations, configuration, runtime resources, prompts, manuals, and tests. +Treat ordinary repository documentation as evidence of intent and context, not +as proof of current behavior. Verify present-tense behavior against current +code and tests. + Use read-only research sub-agents during Phase 1. Assign them independent parts of the codebase so the scan is broader than one agent's linear pass. Good research slices include docs, command surfaces, services/workflows, diff --git a/src/codealmanac/agents/garden/instructions.md b/src/codealmanac/agents/garden/instructions.md index 955ae3aa..ffa1e5b9 100644 --- a/src/codealmanac/agents/garden/instructions.md +++ b/src/codealmanac/agents/garden/instructions.md @@ -53,6 +53,11 @@ You are improving an existing CodeAlmanac wiki as a whole graph. Garden is cultivation. The goal is not to add activity; the goal is to make the project memory more coherent, navigable, current, and trustworthy. +Treat current code as the source of truth for runtime behavior. When the +Almanac disagrees with current code, correct the Almanac. Do not rewrite +Almanac knowledge to match ordinary repository documentation without +verification; the documentation may be stale. + ## Algorithm 1. Inspect pages, topics, Markdown links, hubs, referenced files, cited diff --git a/src/codealmanac/agents/ingest/instructions.md b/src/codealmanac/agents/ingest/instructions.md index 7002abcf..760171eb 100644 --- a/src/codealmanac/agents/ingest/instructions.md +++ b/src/codealmanac/agents/ingest/instructions.md @@ -54,6 +54,11 @@ The input may be a coding session, file, folder, diff, document, docs read, research note, market read, product conversation, incident, user feedback, or other concrete pointer. Treat that input as raw material, not as the output. +Selected documentation is raw material, not authority over the existing +Almanac. Do not overwrite maintained synthesis solely because an ordinary +document conflicts with it. Verify the claim against current code, tests, and +other relevant evidence. + Use the source briefs, source runtime snapshots, and bundled manual text in the runtime context as operation input. The brief identifies the selected source and its provenance hint. The runtime snapshot is readable source material diff --git a/src/codealmanac/app.py b/src/codealmanac/app.py index f9368800..ca69e029 100644 --- a/src/codealmanac/app.py +++ b/src/codealmanac/app.py @@ -18,6 +18,7 @@ default_source_runtime_adapters, default_transcript_discovery_adapters, ) +from codealmanac.integrations.telemetry import SubprocessTelemetrySender from codealmanac.integrations.updates import ( InstalledPackageMetadataProvider, SubprocessPackageCommandRunner, @@ -52,6 +53,9 @@ ) from codealmanac.services.sources.service import SourcesService from codealmanac.services.tagging.service import TaggingService +from codealmanac.services.telemetry.ports import TelemetrySender +from codealmanac.services.telemetry.service import TelemetryService +from codealmanac.services.telemetry.store import TelemetryIdentityStore from codealmanac.services.topics.service import TopicsService from codealmanac.services.updates.ports import ( PackageCommandRunner, @@ -107,6 +111,7 @@ class CodeAlmanac: sources: SourcesService harnesses: HarnessesService manual: ManualLibrary + telemetry: TelemetryService workflows: CodeAlmanacWorkflows @@ -124,6 +129,7 @@ class AppAdapters: instruction_installer: InstructionInstaller | None = None global_state_remover: GlobalStateRemover | None = None package_uninstaller: PackageUninstaller | None = None + telemetry_sender: TelemetrySender | None = None @dataclass(frozen=True) @@ -147,6 +153,7 @@ class Services: sources: SourcesService harnesses: HarnessesService manual: ManualLibrary + telemetry: TelemetryService def create_app( @@ -163,6 +170,7 @@ def create_app( instruction_installer: InstructionInstaller | None = None, global_state_remover: GlobalStateRemover | None = None, package_uninstaller: PackageUninstaller | None = None, + telemetry_sender: TelemetrySender | None = None, ) -> CodeAlmanac: app_config = config or AppConfig() local_state = LocalStatePaths.from_config(app_config) @@ -179,15 +187,10 @@ def create_app( instruction_installer=instruction_installer, global_state_remover=global_state_remover, package_uninstaller=package_uninstaller, + telemetry_sender=telemetry_sender, ) - services = create_services( - local_state, - adapters, - ) - workflows = create_workflows( - services, - adapters, - ) + services = create_services(local_state, adapters) + workflows = create_workflows(services, adapters) return assemble_app(services, workflows) @@ -202,6 +205,12 @@ def create_services( local_state.config_path, automation, ) + telemetry = TelemetryService( + config_service, + TelemetryIdentityStore(local_state.database_path), + adapters.telemetry_sender or SubprocessTelemetrySender(), + __version__, + ) manual = ManualLibrary() wiki = WikiService(repositories, manual) index = IndexService(repositories, IndexStore(), local_state) @@ -240,7 +249,11 @@ def create_services( config_service, runner_probe=harnesses, ) - runs = RunsService(repositories, RunStore(local_state.database_path)) + runs = RunsService( + repositories, + RunStore(local_state.database_path), + telemetry, + ) viewer = ViewerService(repositories, index, runs, MarkdownRenderer()) sources = SourcesService( default_transcript_discovery_adapters() @@ -270,6 +283,7 @@ def create_services( sources=sources, harnesses=harnesses, manual=manual, + telemetry=telemetry, ) @@ -325,6 +339,7 @@ def create_workflows( executor, adapters.executor_spawner or SubprocessRunExecutorSpawner(), cancellation, + services.telemetry, ) sync = SyncWorkflow( services.repositories, @@ -362,5 +377,6 @@ def assemble_app(services: Services, workflows: CodeAlmanacWorkflows) -> CodeAlm sources=services.sources, harnesses=services.harnesses, manual=services.manual, + telemetry=services.telemetry, workflows=workflows, ) diff --git a/src/codealmanac/cli/dispatch/jobs.py b/src/codealmanac/cli/dispatch/jobs.py index ae70f5cc..95d914bb 100644 --- a/src/codealmanac/cli/dispatch/jobs.py +++ b/src/codealmanac/cli/dispatch/jobs.py @@ -3,6 +3,7 @@ from codealmanac.app import CodeAlmanac from codealmanac.cli.render.admin import ( render_run, + render_run_attach_detached, render_run_attach_stream, render_run_cancel, render_run_log, @@ -31,11 +32,18 @@ def dispatch_jobs(args: argparse.Namespace, app: CodeAlmanac) -> int: render_run_log(events, json_output=args.json) return 0 if args.jobs_command == "attach": - updates = app.runs.stream_attach( - StreamRunAttachRequest(repository_name=args.wiki, run_id=args.run_id) - ) - render_run_attach_stream(updates, json_output=args.json) - return 0 + try: + updates = app.runs.stream_attach( + StreamRunAttachRequest( + repository_name=args.wiki, + run_id=args.run_id, + ) + ) + render_run_attach_stream(updates, json_output=args.json) + return 0 + except KeyboardInterrupt: + render_run_attach_detached(args.run_id, json_output=args.json) + return 130 if args.jobs_command == "cancel": result = app.workflows.queue.cancel( CancelRunRequest(repository_name=args.wiki, run_id=args.run_id) diff --git a/src/codealmanac/cli/dispatch/serve.py b/src/codealmanac/cli/dispatch/serve.py index 42471c7e..349de3f7 100644 --- a/src/codealmanac/cli/dispatch/serve.py +++ b/src/codealmanac/cli/dispatch/serve.py @@ -1,15 +1,56 @@ import argparse +import threading +import time +import webbrowser +from contextlib import suppress from pathlib import Path +from typing import TYPE_CHECKING from codealmanac.app import CodeAlmanac +if TYPE_CHECKING: + import uvicorn + + +def open_viewer_when_ready(server: "uvicorn.Server", url: str) -> None: + while not server.started: + if server.should_exit: + return + time.sleep(0.05) + + try: + webbrowser.open_new_tab(url) + except webbrowser.Error: + # The URL remains visible in the terminal on headless systems. + return + def run_serve(app: CodeAlmanac, args: argparse.Namespace) -> int: import uvicorn + from uvicorn.main import STARTUP_FAILURE from codealmanac.server.app import create_server_app - server = create_server_app(app, Path.cwd(), args.wiki) - print(f"codealmanac viewer: http://{args.host}:{args.port}") - uvicorn.run(server, host=args.host, port=args.port, log_level="warning") + server_app = create_server_app(app, Path.cwd(), args.wiki) + url = f"http://{args.host}:{args.port}" + server = uvicorn.Server( + uvicorn.Config( + server_app, + host=args.host, + port=args.port, + log_level="warning", + ) + ) + print(f"codealmanac viewer: {url}") + if not args.no_open: + threading.Thread( + target=open_viewer_when_ready, + args=(server, url), + name="codealmanac-viewer-browser", + daemon=True, + ).start() + with suppress(KeyboardInterrupt): + server.run() + if not server.started: + raise SystemExit(STARTUP_FAILURE) return 0 diff --git a/src/codealmanac/cli/dispatch/setup.py b/src/codealmanac/cli/dispatch/setup.py index 5f1e044a..4d8c0773 100644 --- a/src/codealmanac/cli/dispatch/setup.py +++ b/src/codealmanac/cli/dispatch/setup.py @@ -35,6 +35,7 @@ def dispatch_setup(args: argparse.Namespace, app: CodeAlmanac) -> int: yes=args.yes, auto_commit=selections.auto_commit, auto_update=selections.auto_update, + telemetry_enabled=selections.telemetry_enabled, skip_instructions=args.skip_instructions, sync_every=parse_optional_duration(args.sync_every, "--sync-every") or user_config.automation.sync.every, diff --git a/src/codealmanac/cli/dispatch/setup_tui.py b/src/codealmanac/cli/dispatch/setup_tui.py index 99cdba45..0172aefa 100644 --- a/src/codealmanac/cli/dispatch/setup_tui.py +++ b/src/codealmanac/cli/dispatch/setup_tui.py @@ -18,6 +18,7 @@ target_default_index, target_options, targets_for_index, + telemetry_options, update_options, ) from codealmanac.cli.dispatch.setup_wizard.terminal import ( @@ -27,6 +28,7 @@ ) from codealmanac.cli.render.setup import ( SetupChoiceScreen, + background_item_selection_notices, render_setup_choice_screen, ) from codealmanac.services.config.models import ( @@ -44,7 +46,11 @@ def resolve_setup_selections( defaults = default_setup_selections(args, user_config) if args.yes or args.json or not supports_interactive_setup(): return defaults - return interactive_setup_selections(defaults, runner_status) + return interactive_setup_selections( + defaults, + runner_status, + include_telemetry=not args.no_telemetry, + ) def default_setup_selections( @@ -65,6 +71,7 @@ def default_setup_selections( model=model, auto_update=user_config.automation.update.enabled and not args.no_auto_update, auto_commit=user_config.auto_commit and not args.no_auto_commit, + telemetry_enabled=user_config.telemetry.enabled and not args.no_telemetry, sync_off=args.sync_off or not user_config.automation.sync.enabled, garden_off=args.garden_off or not user_config.automation.garden.enabled, ) @@ -73,10 +80,16 @@ def default_setup_selections( def interactive_setup_selections( defaults: SetupSelections, runner_status: tuple[HarnessReadiness, ...], + *, + include_telemetry: bool, ) -> SetupSelections: try: with wizard_terminal(): - return wizard_selections(defaults, runner_status) + return wizard_selections( + defaults, + runner_status, + include_telemetry=include_telemetry, + ) except KeyboardInterrupt as error: raise SetupCancelled() from error @@ -84,42 +97,49 @@ def interactive_setup_selections( def wizard_selections( defaults: SetupSelections, runner_status: tuple[HarnessReadiness, ...], + *, + include_telemetry: bool, ) -> SetupSelections: - target_index = choose_setup_option( - SetupChoiceScreen( - step=1, - title="Agent instructions", - question="Add CodeAlmanac instructions to your AGENTS.md / CLAUDE.md:", - options=target_options(), - ), - initial_index=target_default_index(defaults.targets), - ) + total_steps = 7 if include_telemetry else 6 runner_index_value = choose_setup_option( SetupChoiceScreen( - step=2, + step=1, title="AI runner", question="Which agent should run CodeAlmanac jobs?", options=runner_options(runner_status), + total_steps=total_steps, ), initial_index=runner_index(defaults.harness), ) harness = runner_for_index(runner_index_value) model_index_value = choose_setup_option( SetupChoiceScreen( - step=3, + step=2, title="Runner model", question=f"Which {harness.value} model should maintain your wiki?", options=model_options(harness), visual="list", + total_steps=total_steps, ), initial_index=model_index(harness, defaults.model), ) + target_index = choose_setup_option( + SetupChoiceScreen( + step=3, + title="Agent instructions", + question="Add CodeAlmanac instructions to your AGENTS.md / CLAUDE.md:", + options=target_options(), + total_steps=total_steps, + ), + initial_index=target_default_index(defaults.targets), + ) maintenance_index = choose_setup_option( SetupChoiceScreen( step=4, title="Wiki maintenance", question="How should your wikis be updated?", options=maintenance_options(), + total_steps=total_steps, ), initial_index=1 if defaults.sync_off and defaults.garden_off else 0, ) @@ -129,6 +149,10 @@ def wizard_selections( title="Product updates", question="Keep CodeAlmanac up to date automatically?", options=update_options(), + total_steps=total_steps, + selection_notices=background_item_selection_notices( + automatic_maintenance=maintenance_index == 0 + ), ), initial_index=0 if defaults.auto_update else 1, ) @@ -139,15 +163,33 @@ def wizard_selections( question="Should agents commit wiki changes or leave them in the worktree?", options=change_options(), visual="change-handling", + total_steps=total_steps, ), initial_index=0 if defaults.auto_commit else 1, ) + telemetry_enabled = defaults.telemetry_enabled + if include_telemetry: + telemetry_index = choose_setup_option( + SetupChoiceScreen( + step=7, + title="Help improve CodeAlmanac", + question=( + "Share anonymous usage so we can focus on what matters and fix " + "broken experiences faster." + ), + options=telemetry_options(), + total_steps=total_steps, + ), + initial_index=0 if defaults.telemetry_enabled else 1, + ) + telemetry_enabled = telemetry_index == 0 return SetupSelections( targets=targets_for_index(target_index), harness=harness, model=model_for_index(harness, model_index_value), auto_update=update_index == 0, auto_commit=change_index == 0, + telemetry_enabled=telemetry_enabled, sync_off=maintenance_index == 1, garden_off=maintenance_index == 1, ) diff --git a/src/codealmanac/cli/dispatch/setup_wizard/models.py b/src/codealmanac/cli/dispatch/setup_wizard/models.py index aa58f522..eed3161e 100644 --- a/src/codealmanac/cli/dispatch/setup_wizard/models.py +++ b/src/codealmanac/cli/dispatch/setup_wizard/models.py @@ -11,6 +11,7 @@ class SetupSelections: model: str auto_update: bool auto_commit: bool + telemetry_enabled: bool sync_off: bool garden_off: bool diff --git a/src/codealmanac/cli/dispatch/setup_wizard/options.py b/src/codealmanac/cli/dispatch/setup_wizard/options.py index 93ff4934..a99b8dfb 100644 --- a/src/codealmanac/cli/dispatch/setup_wizard/options.py +++ b/src/codealmanac/cli/dispatch/setup_wizard/options.py @@ -26,8 +26,15 @@ def target_options() -> tuple[SetupChoiceOption, ...]: def maintenance_options() -> tuple[SetupChoiceOption, ...]: return ( - SetupChoiceOption("Automatic", ()), - SetupChoiceOption("Manual", ()), + SetupChoiceOption( + "Automatic", + ( + "Sync learns from recent sessions", + "Garden improves your wiki", + "Both run locally in background", + ), + ), + SetupChoiceOption("Manual", ("Run Sync or Garden yourself",)), ) @@ -71,8 +78,17 @@ def model_options(harness: HarnessKind) -> tuple[SetupChoiceOption, ...]: def update_options() -> tuple[SetupChoiceOption, ...]: return ( - SetupChoiceOption("Automatic", ()), - SetupChoiceOption("Manual", ()), + SetupChoiceOption( + "Automatic", + ( + "Keep CodeAlmanac updated", + "Runs locally in background", + ), + ), + SetupChoiceOption( + "Manual", + ("Run codealmanac update yourself",), + ), ) @@ -83,6 +99,26 @@ def change_options() -> tuple[SetupChoiceOption, ...]: ) +def telemetry_options() -> tuple[SetupChoiceOption, ...]: + return ( + SetupChoiceOption( + "Yes, help improve CodeAlmanac", + ( + "Recommended", + "Anonymous usage and crash reports", + "Never code, prompts, or transcripts", + ), + ), + SetupChoiceOption( + "No thanks", + ( + "Do not share usage or crashes", + "You can change this later", + ), + ), + ) + + def shortcut_option_index(screen: SetupChoiceScreen, key: str) -> int | None: normalized = key.casefold() for index, option in enumerate(screen.options): diff --git a/src/codealmanac/cli/dispatch/setup_wizard/terminal.py b/src/codealmanac/cli/dispatch/setup_wizard/terminal.py index c45dfad0..877156c2 100644 --- a/src/codealmanac/cli/dispatch/setup_wizard/terminal.py +++ b/src/codealmanac/cli/dispatch/setup_wizard/terminal.py @@ -1,19 +1,30 @@ import os -import select import sys -import termios -import tty from collections.abc import Iterator -from contextlib import contextmanager +from contextlib import contextmanager, suppress + +try: + import select + import termios + import tty + + _HAVE_TERMIOS = True +except ImportError: + _HAVE_TERMIOS = False def supports_interactive_setup() -> bool: + if not _HAVE_TERMIOS: + return False return sys.stdin.isatty() and sys.stdout.isatty() @contextmanager def wizard_terminal() -> Iterator[None]: """Own the terminal for the wizard's lifetime.""" + if not _HAVE_TERMIOS: + yield + return try: fd = sys.stdin.fileno() previous = termios.tcgetattr(fd) @@ -26,19 +37,26 @@ def wizard_terminal() -> Iterator[None]: try: yield finally: - termios.tcsetattr(fd, termios.TCSADRAIN, previous) + with suppress(OSError, termios.error): + termios.tcsetattr(fd, termios.TCSADRAIN, previous) sys.stdout.write("\x1b[?25h\x1b[?1049l") sys.stdout.flush() def read_setup_key() -> str: + if not _HAVE_TERMIOS: + return sys.stdin.read(1) fd = sys.stdin.fileno() sys.stdout.flush() key = os.read(fd, 1).decode("utf-8", errors="ignore") if key != "\x1b": return key for _ in range(2): - ready, _, _ = select.select([fd], [], [], 0.05) + try: + ready, _, _ = select.select([fd], [], [], 0.05) + except OSError: + # select.select might fail on Windows / non-sockets + ready = [] if len(ready) == 0: return key key += os.read(fd, 1).decode("utf-8", errors="ignore") diff --git a/src/codealmanac/cli/execution.py b/src/codealmanac/cli/execution.py new file mode 100644 index 00000000..088644a4 --- /dev/null +++ b/src/codealmanac/cli/execution.py @@ -0,0 +1,79 @@ +import argparse +import sys +import time + +from pydantic import ValidationError + +from codealmanac.app import CodeAlmanac +from codealmanac.cli.dispatch.root import dispatch as dispatch_app +from codealmanac.cli.telemetry import capture_command +from codealmanac.core.errors import CodeAlmanacError + + +def execute( + args: argparse.Namespace, + app: CodeAlmanac, + started_at: float, +) -> int: + if args.command == "uninstall": + app.telemetry.prepare_for_state_removal() + try: + exit_code = dispatch_app(args, app) + except (CodeAlmanacError, ValidationError) as error: + print(f"codealmanac: {error}", file=sys.stderr) + capture_command( + app.telemetry, + args, + outcome="failed", + exit_code=1, + duration_seconds=time.monotonic() - started_at, + ) + return 1 + except KeyboardInterrupt: + capture_command( + app.telemetry, + args, + outcome="interrupted", + exit_code=130, + duration_seconds=time.monotonic() - started_at, + ) + raise + except Exception as error: + command = getattr(args, "command", "unknown") + app.telemetry.capture_exception( + error, + command=command, + process_kind=process_kind(command), + ) + capture_command( + app.telemetry, + args, + outcome="crashed", + exit_code=1, + duration_seconds=time.monotonic() - started_at, + ) + raise + capture_command( + app.telemetry, + args, + outcome=outcome_for_exit_code(exit_code), + exit_code=exit_code, + duration_seconds=time.monotonic() - started_at, + ) + return exit_code + + +def outcome_for_exit_code(exit_code: int) -> str: + if exit_code == 0: + return "success" + if exit_code == 130: + return "interrupted" + return "failed" + + +def process_kind(command: str) -> str: + return { + "__run-worker": "worker", + "__run-executor": "executor", + "__garden-scheduler": "scheduler", + }.get(command, "foreground") diff --git a/src/codealmanac/cli/main.py b/src/codealmanac/cli/main.py index be378a18..5ab70fd8 100644 --- a/src/codealmanac/cli/main.py +++ b/src/codealmanac/cli/main.py @@ -1,32 +1,22 @@ -import argparse -import sys +import time from collections.abc import Sequence -from pydantic import ValidationError - from codealmanac.app import create_app -from codealmanac.cli.dispatch.root import dispatch as dispatch_app +from codealmanac.cli.execution import execute from codealmanac.cli.parser.root import build_parser from codealmanac.cli.render.syntax import render_syntax_problem from codealmanac.cli.syntax.models import CliSyntaxError -from codealmanac.core.errors import CodeAlmanacError def main(argv: Sequence[str] | None = None) -> int: + started_at = time.monotonic() parser = build_parser() try: args = parser.parse_args(argv) - return dispatch(args) except CliSyntaxError as error: render_syntax_problem(error.problem) return 2 - except (CodeAlmanacError, ValidationError) as error: - print(f"codealmanac: {error}", file=sys.stderr) - return 1 - - -def dispatch(args: argparse.Namespace) -> int: - return dispatch_app(args, create_app()) + return execute(args, create_app(), started_at) if __name__ == "__main__": diff --git a/src/codealmanac/cli/parser/setup.py b/src/codealmanac/cli/parser/setup.py index 16a74f66..7f220610 100644 --- a/src/codealmanac/cli/parser/setup.py +++ b/src/codealmanac/cli/parser/setup.py @@ -22,6 +22,11 @@ def add_setup_commands(subcommands: argparse._SubParsersAction) -> None: action="store_true", help="tell run agents not to commit wiki changes", ) + setup.add_argument( + "--no-telemetry", + action="store_true", + help="disable anonymous usage and crash telemetry", + ) setup.add_argument( "--skip-instructions", action="store_true", diff --git a/src/codealmanac/cli/parser/wiki.py b/src/codealmanac/cli/parser/wiki.py index 6968bfd3..e055cba2 100644 --- a/src/codealmanac/cli/parser/wiki.py +++ b/src/codealmanac/cli/parser/wiki.py @@ -79,6 +79,11 @@ def add_wiki_commands(subcommands: argparse._SubParsersAction) -> None: serve.add_argument("--wiki") serve.add_argument("--host", default=DEFAULT_VIEWER_HOST) serve.add_argument("--port", type=int, default=DEFAULT_VIEWER_PORT) + serve.add_argument( + "--no-open", + action="store_true", + help="serve without opening the viewer in a browser", + ) tag = subcommands.add_parser("tag", help="add topics to a page") tag.add_argument("page") diff --git a/src/codealmanac/cli/render/admin.py b/src/codealmanac/cli/render/admin.py index 20a07349..83ca50a8 100644 --- a/src/codealmanac/cli/render/admin.py +++ b/src/codealmanac/cli/render/admin.py @@ -8,6 +8,7 @@ from codealmanac.cli.render.diagnostics import render_doctor from codealmanac.cli.render.job_logs import ( render_run_attach, + render_run_attach_detached, render_run_attach_stream, render_run_log, ) @@ -28,6 +29,7 @@ "render_doctor", "render_run", "render_run_attach", + "render_run_attach_detached", "render_run_attach_stream", "render_run_cancel", "render_run_log", diff --git a/src/codealmanac/cli/render/job_logs.py b/src/codealmanac/cli/render/job_logs.py index abf489e7..e588f222 100644 --- a/src/codealmanac/cli/render/job_logs.py +++ b/src/codealmanac/cli/render/job_logs.py @@ -8,6 +8,7 @@ from codealmanac.services.runs.models import ( RunAttachSnapshot, RunAttachUpdate, + RunId, RunLogEvent, RunRecord, RunStatus, @@ -56,6 +57,13 @@ def render_run_attach_stream( sys.stdout.flush() +def render_run_attach_detached(run_id: RunId, *, json_output: bool) -> None: + if json_output: + return + print("detached — job continues in background") + print(f"cancel: codealmanac jobs cancel {run_id}") + + def print_terminal_status(record: RunRecord) -> None: print(f"status: {status_label(record.status)}") if record.status == RunStatus.FAILED and record.error is not None: diff --git a/src/codealmanac/cli/render/run_commands.py b/src/codealmanac/cli/render/run_commands.py index 0e376730..78c755e4 100644 --- a/src/codealmanac/cli/render/run_commands.py +++ b/src/codealmanac/cli/render/run_commands.py @@ -1,16 +1,17 @@ import json from codealmanac.cli.render.style import style +from codealmanac.cli.render.terminal import card_row, terminal_width, visible_length from codealmanac.services.runs.models import RunKind from codealmanac.workflows.run_queue.models import ( RunQueueStartResult, ScheduledGardenResult, ) -RUN_QUIPS = { - RunKind.BUILD: "every codebase deserves a biography — writing yours now.", - RunKind.INGEST: "the wiki is hungry — feeding it your latest work.", - RunKind.GARDEN: "weeding, pruning, watering. your wiki, but tidier.", +RUN_HEADLINES = { + RunKind.BUILD: "Building your CodeAlmanac in the background", + RunKind.INGEST: "Adding knowledge to your CodeAlmanac in the background", + RunKind.GARDEN: "Improving your CodeAlmanac in the background", } @@ -41,15 +42,41 @@ def render_run_queue_started(result: RunQueueStartResult) -> None: dim = style.DIM bold = style.BOLD rst = style.RST - print(f"{blue}◆{rst} {record.kind.value} queued: {blue}{record.run_id}{rst}") - print(f"{dim}│{rst} repo: {result.repository.name}") - print(f"{dim}│{rst} ahead: {result.runs_ahead} run(s)") - print(f"{dim}│{rst} follow: {bold}codealmanac jobs attach {record.run_id}{rst}") - print(f"{dim}│{rst} details: {bold}codealmanac jobs show {record.run_id}{rst}") - print(f"{dim}│ worker: pid {result.worker.child_pid}{rst}") - quip = RUN_QUIPS.get(record.kind) - if quip is not None: - print(f"{dim}◇ {quip}{rst}") + headline = RUN_HEADLINES[record.kind] + print(f"{blue}◆{rst} {bold}{headline}{rst}") + print() + render_viewer_follow_box() + print() + print(" Prefer the terminal?") + print(f" {bold}codealmanac jobs attach {record.run_id}{rst}") + print() + print( + f"{dim} Job: {record.run_id} · Repo: {result.repository.name}{rst}" + ) + + +def render_viewer_follow_box() -> None: + blue = style.BLUE + dim = style.DIM + bold = style.BOLD + rst = style.RST + border = f"{blue}{dim}" + lines = ( + f" {bold}Follow progress in the CodeAlmanac viewer{rst}", + "", + f" {blue}{bold}codealmanac serve{rst}", + "", + f" Then select {bold}Jobs{rst} in the sidebar.", + ) + available_width = max(40, terminal_width() - 6) + content_width = max(visible_length(line) for line in lines) + width = max(content_width, min(62, available_width)) + print(f" {border}╭{'─' * width}╮{rst}") + print(f" {card_row('', width, border, rst)}") + for line in lines: + print(f" {card_row(line, width, border, rst)}") + print(f" {card_row('', width, border, rst)}") + print(f" {border}╰{'─' * width}╯{rst}") def render_scheduled_garden(result: ScheduledGardenResult) -> None: diff --git a/src/codealmanac/cli/render/setup/__init__.py b/src/codealmanac/cli/render/setup/__init__.py index 42e09200..fed02737 100644 --- a/src/codealmanac/cli/render/setup/__init__.py +++ b/src/codealmanac/cli/render/setup/__init__.py @@ -1,3 +1,8 @@ +from codealmanac.cli.render.setup.background_items import ( + BackgroundItemNotice, + background_item_choice_notice, + background_item_selection_notices, +) from codealmanac.cli.render.setup.result import ( render_setup_result, render_uninstall_result, @@ -9,8 +14,11 @@ ) __all__ = [ + "BackgroundItemNotice", "SetupChoiceOption", "SetupChoiceScreen", + "background_item_choice_notice", + "background_item_selection_notices", "render_setup_choice_screen", "render_setup_result", "render_uninstall_result", diff --git a/src/codealmanac/cli/render/setup/background_items.py b/src/codealmanac/cli/render/setup/background_items.py new file mode 100644 index 00000000..90e9f07b --- /dev/null +++ b/src/codealmanac/cli/render/setup/background_items.py @@ -0,0 +1,96 @@ +from dataclasses import dataclass + +from codealmanac.cli.render.brand import BAR, BLUE, RST, WHITE_BOLD +from codealmanac.cli.render.terminal import wrap_with_prefixes, write_line +from codealmanac.services.automation.models import AutomationTask + + +@dataclass(frozen=True) +class BackgroundItemNotice: + title: str + lines: tuple[str, ...] + + +def render_background_item_notice(notice: BackgroundItemNotice) -> None: + write_line(f" {BLUE}◆{RST} {WHITE_BOLD}{notice.title}{RST}") + for text in notice.lines: + for line in wrap_with_prefixes(text, f"{BAR} ", f"{BAR} ", 78): + write_line(line) + + +def render_selected_background_item_notice( + notices: tuple[BackgroundItemNotice | None, ...], + selected_index: int, +) -> None: + if selected_index >= len(notices): + return + notice = notices[selected_index] + if notice is None: + return + write_line("") + render_background_item_notice(notice) + + +def background_item_choice_notice( + tasks: tuple[AutomationTask, ...], +) -> BackgroundItemNotice | None: + if len(tasks) == 0: + return None + count = len(tasks) + task_word = "task" if count == 1 else "tasks" + notification_word = "notification" if count == 1 else "notifications" + return BackgroundItemNotice( + title="macOS heads-up", + lines=( + f"This adds {count} CodeAlmanac background {task_word}: " + f"{automation_task_names(tasks)}.", + f"macOS may show {count} “Background Items Added” " + f"{notification_word}—one per task.", + "They are expected and all from CodeAlmanac.", + ), + ) + + +def background_item_selection_notices( + automatic_maintenance: bool, +) -> tuple[BackgroundItemNotice | None, ...]: + maintenance_tasks = ( + (AutomationTask.SYNC, AutomationTask.GARDEN) if automatic_maintenance else () + ) + return ( + background_item_choice_notice((*maintenance_tasks, AutomationTask.UPDATE)), + background_item_choice_notice(maintenance_tasks), + ) + + +def background_item_confirmation_notice( + tasks: tuple[AutomationTask, ...], +) -> BackgroundItemNotice | None: + if len(tasks) == 0: + return None + count = len(tasks) + notification_word = "notification" if count == 1 else "notifications" + return BackgroundItemNotice( + title="macOS background items", + lines=( + f"macOS may have shown {count} “Background Items Added” " + f"{notification_word}.", + f"Those are CodeAlmanac's {automation_task_names(tasks)} schedules.", + ), + ) + + +def automation_task_names(tasks: tuple[AutomationTask, ...]) -> str: + names = tuple( + { + AutomationTask.SYNC: "Sync", + AutomationTask.GARDEN: "Garden", + AutomationTask.UPDATE: "Update", + }[task] + for task in tasks + ) + if len(names) == 1: + return names[0] + if len(names) == 2: + return f"{names[0]} and {names[1]}" + return f"{', '.join(names[:-1])}, and {names[-1]}" diff --git a/src/codealmanac/cli/render/setup/result.py b/src/codealmanac/cli/render/setup/result.py index ad65b4c9..fd1da93b 100644 --- a/src/codealmanac/cli/render/setup/result.py +++ b/src/codealmanac/cli/render/setup/result.py @@ -8,6 +8,10 @@ print_banner, ) from codealmanac.cli.render.common import print_json_model +from codealmanac.cli.render.setup.background_items import ( + background_item_confirmation_notice, + render_background_item_notice, +) from codealmanac.cli.render.setup.steps import SetupStep, render_setup_step from codealmanac.cli.render.setup.uninstall import render_uninstall_text from codealmanac.cli.render.terminal import ( @@ -50,10 +54,20 @@ def render_setup_text(result: SetupResult) -> None: render_setup_step(step) if index < len(steps) - 1: write_line(BAR) + background_notice = background_item_confirmation_notice( + enabled_automation_tasks(result) + ) + if background_notice is not None: + write_line(BAR) + render_background_item_notice(background_notice) write_line("") render_next_steps_box(next_step_lines(result)) +def enabled_automation_tasks(result: SetupResult) -> tuple[AutomationTask, ...]: + return tuple(item.task for item in result.config_update.automation if item.enabled) + + def next_step_lines(result: SetupResult) -> tuple[str, ...]: lines: list[str] = [ f" {WHITE_BOLD}Navigate to your repo of choice:{RST}", diff --git a/src/codealmanac/cli/render/setup/screens.py b/src/codealmanac/cli/render/setup/screens.py index daf1cb93..60e17741 100644 --- a/src/codealmanac/cli/render/setup/screens.py +++ b/src/codealmanac/cli/render/setup/screens.py @@ -13,6 +13,10 @@ print_badge, print_banner, ) +from codealmanac.cli.render.setup.background_items import ( + BackgroundItemNotice, + render_selected_background_item_notice, +) from codealmanac.cli.render.terminal import ( card_center_row, card_right_row, @@ -37,6 +41,8 @@ class SetupChoiceScreen: question: str options: tuple[SetupChoiceOption, ...] visual: str = "cards" + total_steps: int = 7 + selection_notices: tuple[BackgroundItemNotice | None, ...] = () def render_setup_choice_screen( @@ -47,11 +53,8 @@ def render_setup_choice_screen( print_banner("The self-updating wiki for your coding agents.") print_badge() write_line("") - write_line( - f" {BLUE}◆{RST} " - f"{DIM}[{screen.step}/6]{RST} " - f"{WHITE_BOLD}{screen.title}{RST}" - ) + progress = f"{DIM}[{screen.step}/{screen.total_steps}]{RST}" + write_line(f" {BLUE}◆{RST} {progress} {WHITE_BOLD}{screen.title}{RST}") write_line(BAR) for line in wrap_with_prefixes(screen.question, f"{BAR} ", f"{BAR} ", 78): write_line(line) @@ -63,6 +66,7 @@ def render_setup_choice_screen( render_vertical_options(screen.options, selected_index) else: render_option_cards(screen.options, selected_index) + render_selected_background_item_notice(screen.selection_notices, selected_index) write_line("") write_line( f" {DIM}│{RST} " @@ -78,12 +82,12 @@ def render_option_cards( selected_index: int, ) -> None: card_width = 21 if len(options) == 3 else 34 + description_rows = max((len(option.description) for option in options), default=0) card_lines = tuple( - option_card(option, card_width, index == selected_index) + option_card(option, card_width, description_rows, index == selected_index) for index, option in enumerate(options) ) - rows = max(len(lines) for lines in card_lines) - for row in range(rows): + for row in range(max(len(lines) for lines in card_lines)): parts = [] for lines in card_lines: parts.append(lines[row] if row < len(lines) else " " * (card_width + 2)) @@ -123,6 +127,7 @@ def render_vertical_options( def option_card( option: SetupChoiceOption, width: int, + description_rows: int, selected: bool, ) -> tuple[str, ...]: enabled = not option.disabled @@ -138,6 +143,8 @@ def option_card( ] for description in option.description: lines.append(card_center_row(f"{body}{description}{RST}", width, border, RST)) + for _ in range(description_rows - len(option.description)): + lines.append(card_row("", width, border, RST)) lines.append(card_row("", width, border, RST)) lines.append(f"{border}╰{'─' * width}╯{RST}") return tuple(lines) diff --git a/src/codealmanac/cli/syntax/catalog.py b/src/codealmanac/cli/syntax/catalog.py index 5f0bf609..e77aa42e 100644 --- a/src/codealmanac/cli/syntax/catalog.py +++ b/src/codealmanac/cli/syntax/catalog.py @@ -244,6 +244,7 @@ def command_catalog() -> CommandCatalog: row("codealmanac serve --port <n>", "Choose port"), row("codealmanac serve --host <host>", "Choose host"), row("codealmanac serve --wiki <name>", "Serve one wiki"), + row("codealmanac serve --no-open", "Do not open a browser"), ), ), guide( diff --git a/src/codealmanac/cli/telemetry.py b/src/codealmanac/cli/telemetry.py new file mode 100644 index 00000000..a450e917 --- /dev/null +++ b/src/codealmanac/cli/telemetry.py @@ -0,0 +1,79 @@ +import argparse +import sys + +from codealmanac.services.telemetry.durations import duration_bucket +from codealmanac.services.telemetry.models import CliCommandCompletedProperties +from codealmanac.services.telemetry.service import TelemetryService + +PUBLIC_COMMANDS = frozenset( + ( + "init", + "ingest", + "garden", + "sync", + "list", + "search", + "show", + "topics", + "health", + "validate", + "reindex", + "serve", + "tag", + "untag", + "config", + "setup", + "uninstall", + "doctor", + "update", + "jobs", + "automation", + ) +) + + +def command_action(args: argparse.Namespace) -> tuple[str, str] | None: + command = args.command + if command not in PUBLIC_COMMANDS: + return None + if command == "sync": + return command, args.sync_command or "run" + if command == "topics": + return command, args.topic_command or "list" + if command == "config": + return command, args.config_command + if command == "jobs": + return command, args.jobs_command or "list" + if command == "automation": + return command, args.automation_command + if command == "update": + if args.scheduled: + return command, "scheduled" + if args.check: + return command, "check" + return command, "run" + return command, command + + +def capture_command( + telemetry: TelemetryService, + args: argparse.Namespace, + *, + outcome: str, + exit_code: int, + duration_seconds: float, +) -> None: + normalized = command_action(args) + if normalized is None: + return + command, action = normalized + telemetry.capture_command( + CliCommandCompletedProperties( + command=command, + action=action, + outcome=outcome, + exit_code=exit_code, + duration_bucket=duration_bucket(duration_seconds), + interactive=sys.stdout.isatty(), + ) + ) diff --git a/src/codealmanac/core/errors.py b/src/codealmanac/core/errors.py index 2d0e61b0..901745ba 100644 --- a/src/codealmanac/core/errors.py +++ b/src/codealmanac/core/errors.py @@ -67,7 +67,10 @@ def __init__(self, message: str): def error_summary(error: Exception) -> str: - text = str(error).strip() + try: + text = str(error).strip() + except Exception: + return error.__class__.__name__ if text == "": return error.__class__.__name__ return text.splitlines()[0] diff --git a/src/codealmanac/integrations/telemetry/__init__.py b/src/codealmanac/integrations/telemetry/__init__.py new file mode 100644 index 00000000..11e36d72 --- /dev/null +++ b/src/codealmanac/integrations/telemetry/__init__.py @@ -0,0 +1,3 @@ +from codealmanac.integrations.telemetry.sender import SubprocessTelemetrySender + +__all__ = ["SubprocessTelemetrySender"] diff --git a/src/codealmanac/integrations/telemetry/sender.py b/src/codealmanac/integrations/telemetry/sender.py new file mode 100644 index 00000000..ca9684e1 --- /dev/null +++ b/src/codealmanac/integrations/telemetry/sender.py @@ -0,0 +1,113 @@ +import json +import os +import subprocess +import sys +from collections.abc import Callable +from functools import partial +from typing import Any + +from codealmanac.services.telemetry.models import TelemetryEvent + +POSTHOG_US_HOST = "https://us.i.posthog.com" +PROJECT_KEY_ENV = "CODEALMANAC_POSTHOG_PROJECT_KEY" +DEFAULT_PROJECT_API_KEY = "phc_ypqXB8PiWLTYn3DsKDyYb4KDEnzNsrhwrQPC8Xqd9K5i" +MAX_EVENT_BYTES = 64 * 1024 + + +class SubprocessTelemetrySender: + def __init__(self, project_api_key: str = DEFAULT_PROJECT_API_KEY): + self.project_api_key = project_api_key + + def send(self, event: TelemetryEvent) -> None: + if not self.project_api_key: + return + payload = event.model_dump_json().encode() + if len(payload) > MAX_EVENT_BYTES: + return + process = subprocess.Popen( + [sys.executable, "-m", "codealmanac.integrations.telemetry.sender"], + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True, + start_new_session=True, + env={**os.environ, PROJECT_KEY_ENV: self.project_api_key}, + ) + if process.stdin is None: + return + process.stdin.write(payload) + process.stdin.close() + + +def deliver( + event: TelemetryEvent, + project_api_key: str, + *, + client_factory: Callable[..., Any] | None = None, +) -> None: + if client_factory is None: + from posthog import Posthog + + client_factory = Posthog + client = client_factory( + project_api_key, + host=POSTHOG_US_HOST, + sync_mode=True, + timeout=5, + disable_geoip=True, + enable_exception_autocapture=False, + capture_exception_code_variables=False, + before_send=partial( + keep_validated_event_properties, + allowed_properties=frozenset(event.properties), + ), + ) + try: + client.capture( + event.name, + distinct_id=event.identity.distinct_id, + properties=event.properties, + uuid=event.event_id, + disable_geoip=True, + ) + finally: + client.shutdown() + + +def keep_validated_event_properties( + message: dict[str, Any], + *, + allowed_properties: frozenset[str], +) -> dict[str, Any] | None: + try: + properties = message.get("properties") + if not isinstance(properties, dict): + return None + message["properties"] = { + key: properties[key] + for key in allowed_properties + if key in properties + } + return message + except Exception: + # PostHog sends the unmodified event when before_send raises. + return None + + +def main() -> int: + try: + payload = sys.stdin.buffer.read(MAX_EVENT_BYTES + 1) + if len(payload) > MAX_EVENT_BYTES: + return 0 + project_api_key = os.environ.get(PROJECT_KEY_ENV, DEFAULT_PROJECT_API_KEY) + if not project_api_key: + return 0 + event = TelemetryEvent.model_validate(json.loads(payload)) + deliver(event, project_api_key) + except Exception: + return 0 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/codealmanac/manual/evidence.md b/src/codealmanac/manual/evidence.md index 85821ccf..b4a95b50 100644 --- a/src/codealmanac/manual/evidence.md +++ b/src/codealmanac/manual/evidence.md @@ -35,6 +35,10 @@ Choose the source that is authoritative for the claim: - PRs and commits are authoritative for review, merge, and change context - the wiki is maintained synthesis, not proof when code or docs disagree +Ordinary repository documentation may be outdated. When using it to create or +change Almanac knowledge, distinguish stated intent from current behavior and +verify present-tense claims against code and tests. + Citations should be close to the claims they support. Do not put all citations only in the lead or only at the end of the page. diff --git a/src/codealmanac/manual/how-to-write.md b/src/codealmanac/manual/how-to-write.md index 46a55954..dcab3f56 100644 --- a/src/codealmanac/manual/how-to-write.md +++ b/src/codealmanac/manual/how-to-write.md @@ -64,9 +64,9 @@ enough. Bad: `OperationRunner` runs the harness. -Better: `OperationRunner` calls `harnesses.run(...)`, so build, ingest, and -garden can use Codex or Claude without knowing provider-specific execution -details. +Better: `OperationRunner` calls `harnesses.ensure_ready(...)` and then +`harnesses.run_ready(...)`, so build, ingest, and garden can use Codex or Claude +without knowing provider-specific execution details. A complete article should feel like an explanation unfolding, not a reference entry. The reader should not feel jumped between disconnected facts. Use diff --git a/src/codealmanac/services/automation/defaults.py b/src/codealmanac/services/automation/defaults.py index 4920587f..a063bfad 100644 --- a/src/codealmanac/services/automation/defaults.py +++ b/src/codealmanac/services/automation/defaults.py @@ -1,7 +1,7 @@ from datetime import timedelta DEFAULT_SYNC_INTERVAL = timedelta(hours=5) -DEFAULT_GARDEN_INTERVAL = timedelta(hours=4) +DEFAULT_GARDEN_INTERVAL = timedelta(hours=24) DEFAULT_UPDATE_INTERVAL = timedelta(days=1) SYNC_LABEL = "com.codealmanac.sync" diff --git a/src/codealmanac/services/config/models.py b/src/codealmanac/services/config/models.py index 99c27e06..24b0b051 100644 --- a/src/codealmanac/services/config/models.py +++ b/src/codealmanac/services/config/models.py @@ -53,6 +53,7 @@ class ConfigKey(StrEnum): AUTO_COMMIT = "auto_commit" + TELEMETRY_ENABLED = "telemetry.enabled" HARNESS_DEFAULT = "harness.default" HARNESS_MODEL = "harness.model" AUTOMATION_SYNC_ENABLED = "automation.sync.enabled" @@ -85,6 +86,10 @@ def model_matches_harness(self) -> "HarnessConfig": return self +class TelemetryConfig(CodeAlmanacModel): + enabled: bool = True + + class TaskAutomationConfig(CodeAlmanacModel): enabled: bool = True every: timedelta @@ -132,6 +137,7 @@ class UserConfig(BaseSettings): auto_commit: bool = DEFAULT_AUTO_COMMIT harness: HarnessConfig = Field(default_factory=HarnessConfig) + telemetry: TelemetryConfig = Field(default_factory=TelemetryConfig) automation: AutomationConfig = Field(default_factory=AutomationConfig) @classmethod diff --git a/src/codealmanac/services/config/requests.py b/src/codealmanac/services/config/requests.py index cff603ab..822fc0f9 100644 --- a/src/codealmanac/services/config/requests.py +++ b/src/codealmanac/services/config/requests.py @@ -8,6 +8,7 @@ AutomationConfig, ConfigKey, HarnessConfig, + TelemetryConfig, ) @@ -34,4 +35,5 @@ class ApplyConfigRequest(CodeAlmanacModel): class UpdateUserConfigRequest(ApplyConfigRequest): auto_commit: bool harness: HarnessConfig + telemetry: TelemetryConfig automation: AutomationConfig diff --git a/src/codealmanac/services/config/service.py b/src/codealmanac/services/config/service.py index ac42db9d..42d2fba4 100644 --- a/src/codealmanac/services/config/service.py +++ b/src/codealmanac/services/config/service.py @@ -135,6 +135,10 @@ def config_entries(config: UserConfig) -> tuple[ConfigEntry, ...]: key=ConfigKey.HARNESS_MODEL, value=config.harness.model, ), + ConfigEntry( + key=ConfigKey.TELEMETRY_ENABLED, + value=format_bool(config.telemetry.enabled), + ), *automation_entries(config.automation), ) @@ -147,6 +151,9 @@ def config_value_updates( if key == ConfigKey.AUTO_COMMIT: normalized = parse_bool_value(request.value, key.value) return normalized, (TomlValueUpdate(None, "auto_commit", normalized),) + if key == ConfigKey.TELEMETRY_ENABLED: + normalized = parse_bool_value(request.value, key.value) + return normalized, (TomlValueUpdate("telemetry", "enabled", normalized),) if key == ConfigKey.HARNESS_DEFAULT: normalized = parse_harness_value(request.value) model = DEFAULT_HARNESS_MODELS[HarnessKind(normalized)] @@ -180,6 +187,11 @@ def user_config_updates( quoted(request.harness.default.value), ), TomlValueUpdate("harness", "model", quoted(request.harness.model)), + TomlValueUpdate( + "telemetry", + "enabled", + format_bool(request.telemetry.enabled), + ), *automation_toml_updates("automation.sync", request.automation.sync), *automation_toml_updates("automation.garden", request.automation.garden), *automation_toml_updates("automation.update", request.automation.update), diff --git a/src/codealmanac/services/harnesses/service.py b/src/codealmanac/services/harnesses/service.py index 1e8056e7..f53cad32 100644 --- a/src/codealmanac/services/harnesses/service.py +++ b/src/codealmanac/services/harnesses/service.py @@ -2,6 +2,7 @@ from codealmanac.core.errors import ConflictError, ExecutionFailed, NotFoundError from codealmanac.services.harnesses.models import ( + HarnessEvent, HarnessKind, HarnessReadiness, HarnessRunResult, @@ -10,6 +11,18 @@ from codealmanac.services.harnesses.requests import RunHarnessRequest +class HarnessUnavailable(ExecutionFailed): + """The selected harness exists but cannot currently start a run.""" + + +class HarnessEventSinkFailed(Exception): + """The caller's event sink failed during an otherwise provider-owned run.""" + + def __init__(self, error: Exception): + super().__init__(error.__class__.__name__) + self.error = error + + class HarnessesService: def __init__(self, adapters: Sequence[HarnessAdapter] = ()): self.adapters = adapters_by_kind(adapters) @@ -32,17 +45,25 @@ def ensure_ready(self, kind: HarnessKind) -> HarnessReadiness: readiness = self.adapter_for(kind).check() if readiness.available: return readiness - raise ExecutionFailed( + raise HarnessUnavailable( unavailable_harness_message(readiness, self.alternatives_to(kind)) ) - def run( + def run_ready( self, request: RunHarnessRequest, on_event: HarnessEventSink | None = None, ) -> HarnessRunResult: - self.ensure_ready(request.kind) - return self.adapter_for(request.kind).run(request, on_event) + if on_event is None: + return self.adapter_for(request.kind).run(request) + + def guarded_event_sink(event: HarnessEvent) -> None: + try: + on_event(event) + except Exception as error: + raise HarnessEventSinkFailed(error) from error + + return self.adapter_for(request.kind).run(request, guarded_event_sink) def alternatives_to(self, kind: HarnessKind) -> tuple[HarnessKind, ...]: return tuple(other for other in self.adapters if other != kind) diff --git a/src/codealmanac/services/index/models.py b/src/codealmanac/services/index/models.py index f6ca8cd9..9e781b3b 100644 --- a/src/codealmanac/services/index/models.py +++ b/src/codealmanac/services/index/models.py @@ -24,6 +24,8 @@ class SearchPageResult(CodeAlmanacModel): file_path: Path updated_at: int topics: tuple[str, ...] + matched_heading: str | None = None + excerpt: str | None = None class PageFileReference(CodeAlmanacModel): diff --git a/src/codealmanac/services/index/projection.py b/src/codealmanac/services/index/projection.py index de68bf41..55e2cf4e 100644 --- a/src/codealmanac/services/index/projection.py +++ b/src/codealmanac/services/index/projection.py @@ -4,6 +4,7 @@ from codealmanac.services.index.models import IndexSourceSignature from codealmanac.services.index.sources import LoadedIndexSources from codealmanac.services.wiki.models import PageDocument +from codealmanac.services.wiki.sections import WikiSection, project_sections from codealmanac.services.wiki.topics import TopicDefinition, title_for_slug SOURCE_SIGNATURE_KEY = "source_signature" @@ -27,7 +28,7 @@ def replace_documents( sources: LoadedIndexSources, ) -> None: with connection: - connection.execute("DELETE FROM fts_pages") + connection.execute("DELETE FROM fts_sections") connection.execute("DELETE FROM pages") connection.execute("DELETE FROM topic_parents") connection.execute("DELETE FROM topics") @@ -66,10 +67,8 @@ def insert_document(connection: SQLiteConnection, document: PageDocument) -> Non document.body, ), ) - connection.execute( - "INSERT INTO fts_pages (slug, title, content) VALUES (?, ?, ?)", - (document.slug, document.title, document.body), - ) + for wiki_section in project_sections(document.body, document.title): + insert_section(connection, document, wiki_section) for topic in document.topics: if not topic: continue @@ -153,3 +152,38 @@ def insert_topic_definition( """, (topic.slug, parent), ) + + +def insert_section( + connection: SQLiteConnection, + document: PageDocument, + wiki_section: WikiSection, +) -> None: + connection.execute( + """ + INSERT INTO page_sections + (page_slug, section_id, heading_path, ordinal, body) + VALUES (?, ?, ?, ?, ?) + """, + ( + document.slug, + wiki_section.section_id, + wiki_section.heading, + wiki_section.ordinal, + wiki_section.body, + ), + ) + connection.execute( + """ + INSERT INTO fts_sections + (page_slug, section_id, page_title, heading, body) + VALUES (?, ?, ?, ?, ?) + """, + ( + document.slug, + wiki_section.section_id, + document.title, + wiki_section.heading, + wiki_section.body, + ), + ) diff --git a/src/codealmanac/services/index/query.py b/src/codealmanac/services/index/query.py new file mode 100644 index 00000000..32c71543 --- /dev/null +++ b/src/codealmanac/services/index/query.py @@ -0,0 +1,12 @@ +import re + +SEARCH_TOKEN = re.compile(r"\w+", re.UNICODE) + + +def analyze_search_query(raw: str) -> str | None: + """Return an escaped, recall-oriented FTS5 prefix expression.""" + tokens = dict.fromkeys(SEARCH_TOKEN.findall(raw.casefold())) + if not tokens: + return None + return " OR ".join(f'"{token}"*' for token in tokens) + diff --git a/src/codealmanac/services/index/schema.py b/src/codealmanac/services/index/schema.py index d6f076a4..448cce49 100644 --- a/src/codealmanac/services/index/schema.py +++ b/src/codealmanac/services/index/schema.py @@ -7,7 +7,7 @@ connect_sqlite, ) -SCHEMA_VERSION = 2026070601 +SCHEMA_VERSION = 2026071401 SCHEMA_DDL = """ CREATE TABLE IF NOT EXISTS pages ( @@ -75,7 +75,22 @@ PRIMARY KEY (source_slug, target_wiki, target_slug) ); -CREATE VIRTUAL TABLE IF NOT EXISTS fts_pages USING fts5(slug, title, content); +CREATE TABLE IF NOT EXISTS page_sections ( + page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE, + section_id TEXT NOT NULL, + heading_path TEXT NOT NULL, + ordinal INTEGER NOT NULL, + body TEXT NOT NULL, + PRIMARY KEY (page_slug, section_id) +); + +CREATE VIRTUAL TABLE IF NOT EXISTS fts_sections USING fts5( + page_slug, + section_id UNINDEXED, + page_title, + heading, + body +); CREATE TABLE IF NOT EXISTS index_metadata ( key TEXT PRIMARY KEY, @@ -92,8 +107,10 @@ DROP TABLE IF EXISTS page_topics; DROP TABLE IF EXISTS topic_parents; DROP TABLE IF EXISTS topics; -DROP TABLE IF EXISTS pages; DROP TABLE IF EXISTS fts_pages; +DROP TABLE IF EXISTS page_sections; +DROP TABLE IF EXISTS fts_sections; +DROP TABLE IF EXISTS pages; DROP TABLE IF EXISTS index_metadata; """ diff --git a/src/codealmanac/services/index/search_views.py b/src/codealmanac/services/index/search_views.py index eb5f6c75..55e75669 100644 --- a/src/codealmanac/services/index/search_views.py +++ b/src/codealmanac/services/index/search_views.py @@ -1,10 +1,10 @@ -import re from pathlib import Path from codealmanac.core.slug import to_kebab_case from codealmanac.database import SQLiteConnection, SQLiteRow from codealmanac.services.index.models import SearchPageResult from codealmanac.services.index.page_views import topics_for_page +from codealmanac.services.index.query import analyze_search_query from codealmanac.services.index.requests import SearchIndexRequest from codealmanac.services.wiki.paths import ( escape_glob_meta, @@ -19,10 +19,7 @@ def search_pages( request: SearchIndexRequest, ) -> tuple[SearchPageResult, ...]: rows = connection.execute(*search_sql(request)).fetchall() - results = [search_result_from_row(connection, row) for row in rows] - if request.limit is not None: - return tuple(results[: request.limit]) - return tuple(results) + return tuple(search_result_from_row(connection, row) for row in rows) def search_sql(request: SearchIndexRequest) -> tuple[str, tuple[object, ...]]: @@ -45,17 +42,62 @@ def search_sql(request: SearchIndexRequest) -> tuple[str, tuple[object, ...]]: if request.mentions is not None and request.mentions.strip(): append_file_mention_clause(where_clauses, params, request.mentions) - query = (request.query or "").strip() - if query: - where_clauses.insert(0, "fts_pages MATCH ?") - params.insert(0, build_fts_query(query)) + query = analyze_search_query(request.query or "") + if query is not None: + where_clauses.insert(0, "fts_sections MATCH ?") + params.insert(0, query) + limit_sql = "" + if request.limit is not None: + limit_sql = "LIMIT ?" + params.append(request.limit) return ( f""" - SELECT p.slug, p.title, p.summary, p.file_path, p.updated_at - FROM pages p - JOIN fts_pages f ON f.slug = p.slug - WHERE {" AND ".join(where_clauses)} - ORDER BY rank, p.updated_at DESC, p.slug ASC + WITH matched_sections AS MATERIALIZED ( + SELECT + p.slug, + p.title, + p.summary, + p.file_path, + p.updated_at, + ps.section_id, + ps.heading_path AS matched_heading, + NULLIF( + TRIM(snippet(fts_sections, 4, '', '', ' … ', 32)), + '' + ) AS excerpt, + bm25(fts_sections, 0.0, 0.0, 5.0, 3.0, 1.0) AS search_rank, + ps.ordinal AS section_ordinal + FROM pages p + JOIN page_sections ps ON ps.page_slug = p.slug + JOIN fts_sections + ON fts_sections.page_slug = ps.page_slug + AND fts_sections.section_id = ps.section_id + WHERE {" AND ".join(where_clauses)} + ), + ranked_sections AS ( + SELECT + *, + ROW_NUMBER() OVER ( + PARTITION BY slug + ORDER BY search_rank ASC, section_ordinal ASC, section_id ASC + ) AS page_section_rank + FROM matched_sections + ) + SELECT + slug, + title, + summary, + file_path, + updated_at, + matched_heading, + excerpt + FROM ranked_sections + WHERE page_section_rank = 1 + ORDER BY + search_rank ASC, + updated_at DESC, + slug ASC + {limit_sql} """, tuple(params), ) @@ -63,12 +105,24 @@ def search_sql(request: SearchIndexRequest) -> tuple[str, tuple[object, ...]]: where_sql = ( f"WHERE {' AND '.join(where_clauses)}" if len(where_clauses) > 0 else "" ) + limit_sql = "" + if request.limit is not None: + limit_sql = "LIMIT ?" + params.append(request.limit) return ( f""" - SELECT p.slug, p.title, p.summary, p.file_path, p.updated_at + SELECT + p.slug, + p.title, + p.summary, + p.file_path, + p.updated_at, + NULL AS matched_heading, + NULL AS excerpt FROM pages p {where_sql} ORDER BY p.updated_at DESC, p.slug ASC + {limit_sql} """, tuple(params), ) @@ -123,14 +177,6 @@ def append_file_mention_clause( params.extend([normalized, *parent_folders]) -def build_fts_query(raw: str) -> str: - tokens = re.split(r"[^a-zA-Z0-9]+", raw.casefold()) - clean = [token for token in tokens if token] - if not clean: - return '""' - return " AND ".join(f"{token}*" for token in clean) - - def search_result_from_row( connection: SQLiteConnection, row: SQLiteRow, @@ -142,4 +188,6 @@ def search_result_from_row( file_path=Path(row["file_path"]), updated_at=row["updated_at"], topics=topics_for_page(connection, row["slug"]), + matched_heading=row["matched_heading"], + excerpt=row["excerpt"], ) diff --git a/src/codealmanac/services/runs/models.py b/src/codealmanac/services/runs/models.py index 2509739a..9780753c 100644 --- a/src/codealmanac/services/runs/models.py +++ b/src/codealmanac/services/runs/models.py @@ -33,6 +33,16 @@ class RunStatus(StrEnum): CANCELLED = "cancelled" +class RunFailureCategory(StrEnum): + HARNESS_READINESS = "harness_readiness" + PROVIDER_EXECUTION = "provider_execution" + SOURCE_PREPARATION = "source_preparation" + MUTATION_SAFETY = "mutation_safety" + WIKI_VALIDATION = "wiki_validation" + INDEXING = "indexing" + INTERNAL_ERROR = "internal_error" + + class RunEventKind(StrEnum): STATUS = "status" MESSAGE = "message" @@ -41,6 +51,12 @@ class RunEventKind(StrEnum): ERROR = "error" +class RunWorkerIdleHandoffOutcome(StrEnum): + WORK_AVAILABLE = "work_available" + RELEASED = "released" + OWNERSHIP_LOST = "ownership_lost" + + class PageChangeSet(CodeAlmanacModel): created: tuple[str, ...] = () updated: tuple[str, ...] = () @@ -73,6 +89,7 @@ class RunRecord(CodeAlmanacModel): title: str | None summary: str | None = None error: str | None = None + failure_category: RunFailureCategory | None = None created_at: datetime updated_at: datetime started_at: datetime | None = None @@ -87,6 +104,14 @@ class RunRecord(CodeAlmanacModel): def require_repository_id(cls, value: str) -> str: return required_text(value, "repository_id") + @model_validator(mode="after") + def failure_category_matches_status(self) -> "RunRecord": + # Failed records written before this field existed must remain readable. + # FinishRunRequest requires the category for every new failed transition. + if self.status != RunStatus.FAILED and self.failure_category is not None: + raise ValueError("failure_category is only valid for failed runs") + return self + class RunLogEvent(CodeAlmanacModel): run_id: RunId @@ -119,6 +144,11 @@ class RunCancelResult(CodeAlmanacModel): changed: bool +class RunFinishResult(CodeAlmanacModel): + record: RunRecord + changed: bool + + class RunCancellationPlan(CodeAlmanacModel): record: RunRecord changed: bool diff --git a/src/codealmanac/services/runs/requests.py b/src/codealmanac/services/runs/requests.py index e06bc032..ba9788a6 100644 --- a/src/codealmanac/services/runs/requests.py +++ b/src/codealmanac/services/runs/requests.py @@ -1,7 +1,7 @@ from datetime import datetime, timedelta from pathlib import Path -from pydantic import field_validator +from pydantic import field_validator, model_validator from codealmanac.core.models import CodeAlmanacModel from codealmanac.core.text import required_text @@ -10,10 +10,12 @@ from codealmanac.services.runs.models import ( RunEventKind, RunExecutionRef, + RunFailureCategory, RunId, RunKind, RunSpec, RunStatus, + RunWorkerLockOwner, ) @@ -115,6 +117,10 @@ def positive_pid(cls, value: int | None) -> int | None: return value +class ReleaseRunWorkerIfIdleRequest(CodeAlmanacModel): + owner: RunWorkerLockOwner + + class SpawnRunWorkerRequest(CodeAlmanacModel): cwd: Path @@ -151,6 +157,7 @@ class FinishRunRequest(CodeAlmanacModel): status: RunStatus summary: str | None = None error: str | None = None + failure_category: RunFailureCategory | None = None @field_validator("status") @classmethod @@ -158,3 +165,11 @@ def terminal_status(cls, value: RunStatus) -> RunStatus: if value not in {RunStatus.DONE, RunStatus.FAILED, RunStatus.CANCELLED}: raise ValueError("finish status must be done, failed, or cancelled") return value + + @model_validator(mode="after") + def failure_category_matches_status(self) -> "FinishRunRequest": + if self.status == RunStatus.FAILED and self.failure_category is None: + raise ValueError("failed runs require failure_category") + if self.status != RunStatus.FAILED and self.failure_category is not None: + raise ValueError("failure_category is only valid for failed runs") + return self diff --git a/src/codealmanac/services/runs/service.py b/src/codealmanac/services/runs/service.py index c03c9976..60c2b133 100644 --- a/src/codealmanac/services/runs/service.py +++ b/src/codealmanac/services/runs/service.py @@ -8,6 +8,7 @@ from codealmanac.services.repositories.service import RepositoriesService from codealmanac.services.runs.locks import RunWorkerLease from codealmanac.services.runs.models import ( + TERMINAL_RUN_STATUSES, QueuedRun, RunAttachSnapshot, RunAttachUpdate, @@ -17,6 +18,7 @@ RunLogEvent, RunRecord, RunSpec, + RunWorkerIdleHandoffOutcome, ) from codealmanac.services.runs.requests import ( AcquireRunWorkerLockRequest, @@ -31,12 +33,14 @@ ReadRunSpecRequest, RecordRunEventRequest, RecordRunHarnessTranscriptRequest, + ReleaseRunWorkerIfIdleRequest, ShowRunRequest, StartRunRequest, StreamRunAttachRequest, ) from codealmanac.services.runs.store import RunStore from codealmanac.services.runs.streaming import RunAttachStreamer +from codealmanac.services.telemetry.service import TelemetryService class RunsService: @@ -44,10 +48,12 @@ def __init__( self, repositories: RepositoriesService, store: RunStore, + telemetry: TelemetryService, streamer: RunAttachStreamer | None = None, ): self.repositories = repositories self.store = store + self.telemetry = telemetry self.streamer = streamer or RunAttachStreamer(store) def start(self, request: StartRunRequest) -> RunRecord: @@ -102,6 +108,12 @@ def acquire_worker_lock( request.stale_after, ) + def release_worker_if_idle( + self, + request: ReleaseRunWorkerIfIdleRequest, + ) -> RunWorkerIdleHandoffOutcome: + return self.store.release_worker_if_idle(request.owner) + def log(self, request: ReadRunLogRequest) -> tuple[RunLogEvent, ...]: record = self.store.read(request.run_id) self.require_run_matches_repository(record, request.repository_name) @@ -144,12 +156,16 @@ def record_harness_transcript( ) def finish(self, request: FinishRunRequest) -> RunRecord: - return self.store.finish( + result = self.store.finish( request.run_id, request.status, request.summary, request.error, + request.failure_category, ) + if result.changed: + self.capture_terminal_telemetry(result.record) + return result.record def prepare_cancellation( self, @@ -157,16 +173,30 @@ def prepare_cancellation( ) -> RunCancellationPlan: record = self.store.read(request.run_id) self.require_run_matches_repository(record, request.repository_name) - return self.store.prepare_cancellation(request.run_id) + plan = self.store.prepare_cancellation(request.run_id) + if plan.changed and plan.record.status in TERMINAL_RUN_STATUSES: + self.capture_terminal_telemetry(plan.record) + return plan def finish_cancellation( self, request: FinishRunCancellationRequest, ) -> RunCancelResult: - return self.store.finish_cancellation( + result = self.store.finish_cancellation( request.run_id, request.execution_id, ) + if result.changed and result.record.status in TERMINAL_RUN_STATUSES: + self.capture_terminal_telemetry(result.record) + return result + + def capture_terminal_telemetry(self, record: RunRecord) -> None: + try: + spec = self.store.read_spec(record.run_id) + self.telemetry.capture_lifecycle(record, spec) + except Exception: + # Telemetry-only work must not change a durable transition's result. + return def repository_filter( self, diff --git a/src/codealmanac/services/runs/store.py b/src/codealmanac/services/runs/store.py index 485f0af8..ac5ad822 100644 --- a/src/codealmanac/services/runs/store.py +++ b/src/codealmanac/services/runs/store.py @@ -19,11 +19,15 @@ RunCancelResult, RunEventKind, RunExecutionRef, + RunFailureCategory, + RunFinishResult, RunKind, RunLogEvent, RunRecord, RunSpec, RunStatus, + RunWorkerIdleHandoffOutcome, + RunWorkerLockOwner, ) from codealmanac.services.runs.queries import ( active_run_exists, @@ -143,6 +147,12 @@ def acquire_worker_lock( ) -> RunWorkerLease | None: return self.lock_store.acquire(owner, pid, now, stale_after) + def release_worker_if_idle( + self, + owner: RunWorkerLockOwner, + ) -> RunWorkerIdleHandoffOutcome: + return self.lock_store.release_if_idle(owner) + def log(self, run_id: str) -> tuple[RunLogEvent, ...]: self.read(run_id) return self.event_store.list(run_id) @@ -223,16 +233,24 @@ def finish( status: RunStatus, summary: str | None, error: str | None, - ) -> RunRecord: + failure_category: RunFailureCategory | None, + ) -> RunFinishResult: now = datetime.now(UTC) with self.connect() as connection: connection.execute("BEGIN IMMEDIATE") record, spec = self.read_with_spec_on_connection(connection, run_id) - finished = finish_run(record, status, summary, error, now) - if finished is record: + result = finish_run( + record, + status, + summary, + error, + failure_category, + now, + ) + if not result.changed: connection.commit() - return record - self.write_record_on_connection(connection, finished, spec) + return result + self.write_record_on_connection(connection, result.record, spec) self.event_store.append_on_connection( connection, run_id, @@ -241,7 +259,7 @@ def finish( status.value, ) connection.commit() - return finished + return result def prepare_cancellation(self, run_id: str) -> RunCancellationPlan: now = datetime.now(UTC) diff --git a/src/codealmanac/services/runs/transitions.py b/src/codealmanac/services/runs/transitions.py index 9510c1df..809624e8 100644 --- a/src/codealmanac/services/runs/transitions.py +++ b/src/codealmanac/services/runs/transitions.py @@ -7,6 +7,8 @@ RunCancellationPlan, RunCancelResult, RunExecutionRef, + RunFailureCategory, + RunFinishResult, RunKind, RunRecord, RunStatus, @@ -58,18 +60,23 @@ def finish_run( status: RunStatus, summary: str | None, error: str | None, + failure_category: RunFailureCategory | None, now: datetime, -) -> RunRecord: - if record.status == RunStatus.CANCELLED: - return record - return record.model_copy( - update={ - "status": status, - "summary": summary, - "error": error, - "updated_at": now, - "finished_at": now, - } +) -> RunFinishResult: + if record.status in TERMINAL_RUN_STATUSES: + return RunFinishResult(record=record, changed=False) + return RunFinishResult( + record=record.model_copy( + update={ + "status": status, + "summary": summary, + "error": error, + "failure_category": failure_category, + "updated_at": now, + "finished_at": now, + } + ), + changed=True, ) diff --git a/src/codealmanac/services/runs/worker_locks.py b/src/codealmanac/services/runs/worker_locks.py index e3ab241e..f2616c14 100644 --- a/src/codealmanac/services/runs/worker_locks.py +++ b/src/codealmanac/services/runs/worker_locks.py @@ -4,8 +4,12 @@ from codealmanac.core.paths import normalize_path from codealmanac.database.local import open_local_database from codealmanac.services.runs.locks import RunWorkerLease, worker_lock_is_stale -from codealmanac.services.runs.models import RunWorkerLockOwner -from codealmanac.services.runs.tables import WORKER_LOCK_TABLES +from codealmanac.services.runs.models import ( + RunWorkerIdleHandoffOutcome, + RunWorkerLockOwner, +) +from codealmanac.services.runs.queries import next_queued_run +from codealmanac.services.runs.tables import RUN_TABLES, WORKER_LOCK_TABLES WORKER_LOCK_NAME = "runs" @@ -23,6 +27,7 @@ def acquire( ) -> RunWorkerLease | None: requested = RunWorkerLockOwner(owner=owner, pid=pid, acquired_at=now) with self.connect() as connection: + connection.execute("BEGIN IMMEDIATE") row = connection.execute( "SELECT owner_json FROM worker_locks WHERE name = ?", (WORKER_LOCK_NAME,), @@ -30,6 +35,7 @@ def acquire( if row is not None: current = RunWorkerLockOwner.model_validate_json(row["owner_json"]) if not worker_lock_is_stale(current, now, stale_after): + connection.commit() return None connection.execute( """ @@ -44,14 +50,17 @@ def acquire( def release(self, owner: RunWorkerLockOwner) -> None: with self.connect() as connection: + connection.execute("BEGIN IMMEDIATE") row = connection.execute( "SELECT owner_json FROM worker_locks WHERE name = ?", (WORKER_LOCK_NAME,), ).fetchone() if row is None: + connection.commit() return current = RunWorkerLockOwner.model_validate_json(row["owner_json"]) if current != owner: + connection.commit() return connection.execute( "DELETE FROM worker_locks WHERE name = ?", @@ -59,5 +68,35 @@ def release(self, owner: RunWorkerLockOwner) -> None: ) connection.commit() + def release_if_idle( + self, + owner: RunWorkerLockOwner, + ) -> RunWorkerIdleHandoffOutcome: + with self.connect() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + "SELECT owner_json FROM worker_locks WHERE name = ?", + (WORKER_LOCK_NAME,), + ).fetchone() + if row is None: + connection.commit() + return RunWorkerIdleHandoffOutcome.OWNERSHIP_LOST + current = RunWorkerLockOwner.model_validate_json(row["owner_json"]) + if current != owner: + connection.commit() + return RunWorkerIdleHandoffOutcome.OWNERSHIP_LOST + if next_queued_run(connection) is not None: + connection.commit() + return RunWorkerIdleHandoffOutcome.WORK_AVAILABLE + connection.execute( + "DELETE FROM worker_locks WHERE name = ?", + (WORKER_LOCK_NAME,), + ) + connection.commit() + return RunWorkerIdleHandoffOutcome.RELEASED + def connect(self): - return open_local_database(self.database_path, WORKER_LOCK_TABLES) + return open_local_database( + self.database_path, + RUN_TABLES + WORKER_LOCK_TABLES, + ) diff --git a/src/codealmanac/services/setup/agent-guide.md b/src/codealmanac/services/setup/agent-guide.md index c5147a52..995a0f2d 100644 --- a/src/codealmanac/services/setup/agent-guide.md +++ b/src/codealmanac/services/setup/agent-guide.md @@ -137,6 +137,19 @@ codealmanac show PAGE --json The Markdown files under `almanac/` may also be read directly when raw source is more useful than the indexed view. +## Citing Almanac in final answers + +When CodeAlmanac contributes to your final answer, cite the Almanac page that +supplied that knowledge. + +Place each citation inline, immediately after the claim it supports. Link to +the committed Markdown page using its repository-relative path: + +```md +Setup installs the same guide for both supported agents +([Instruction Installation](almanac/architecture/setup/instruction-installation.md)). +``` + ## Browsing topics and repositories Topics provide a read-only way to browse an area of the wiki: @@ -168,16 +181,12 @@ codealmanac validate `health` explains problems with the page graph, links, evidence, and topics. `validate` provides a pass-or-fail verification result suitable for scripts. -The wiki is maintained synthesis, not a replacement for primary evidence: - -- Code is authoritative for current runtime behavior. -- Tests are authoritative for enforced behavior. -- Repository documentation records stated intent. -- Git history records how and when behavior changed. +**Trust order: current code > Almanac > ordinary repository documentation.** -When the wiki and code disagree, trust the code for current behavior. Do not -propagate a contradicted wiki claim into new code. Report the discrepancy -clearly so the wiki can be corrected by its maintenance workflows. +When code and the Almanac disagree, trust the code. When the Almanac and +ordinary documentation disagree, trust the Almanac. Do not prefer ordinary +docs over the Almanac; the Almanac is maintained more regularly and is more +authoritative. ## Maintenance boundary diff --git a/src/codealmanac/services/setup/requests.py b/src/codealmanac/services/setup/requests.py index 1392be4a..7821c2f6 100644 --- a/src/codealmanac/services/setup/requests.py +++ b/src/codealmanac/services/setup/requests.py @@ -24,6 +24,7 @@ class RunSetupRequest(CodeAlmanacModel): yes: bool = False auto_commit: bool = True auto_update: bool = True + telemetry_enabled: bool = True skip_instructions: bool = False home: Path | None = None sync_every: timedelta = DEFAULT_SYNC_INTERVAL diff --git a/src/codealmanac/services/setup/service.py b/src/codealmanac/services/setup/service.py index af4c13c7..d5e7a772 100644 --- a/src/codealmanac/services/setup/service.py +++ b/src/codealmanac/services/setup/service.py @@ -5,6 +5,7 @@ GardenAutomationConfig, HarnessConfig, SyncAutomationConfig, + TelemetryConfig, UpdateAutomationConfig, ) from codealmanac.services.config.requests import UpdateUserConfigRequest @@ -79,6 +80,7 @@ def set_config(self, request: RunSetupRequest) -> ConfigUpdateResult: default=request.harness, model=request.model, ), + telemetry=TelemetryConfig(enabled=request.telemetry_enabled), automation=AutomationConfig( sync=SyncAutomationConfig( enabled=not request.sync_off, diff --git a/src/codealmanac/services/telemetry/__init__.py b/src/codealmanac/services/telemetry/__init__.py new file mode 100644 index 00000000..81de6a49 --- /dev/null +++ b/src/codealmanac/services/telemetry/__init__.py @@ -0,0 +1 @@ +"""Privacy-conscious product telemetry.""" diff --git a/src/codealmanac/services/telemetry/durations.py b/src/codealmanac/services/telemetry/durations.py new file mode 100644 index 00000000..50040e0d --- /dev/null +++ b/src/codealmanac/services/telemetry/durations.py @@ -0,0 +1,12 @@ +def duration_bucket(seconds: float) -> str: + if seconds < 0.25: + return "<250ms" + if seconds < 1: + return "250ms-1s" + if seconds < 10: + return "1-10s" + if seconds < 60: + return "10-60s" + if seconds < 300: + return "1-5m" + return "5m+" diff --git a/src/codealmanac/services/telemetry/models.py b/src/codealmanac/services/telemetry/models.py new file mode 100644 index 00000000..68827ee2 --- /dev/null +++ b/src/codealmanac/services/telemetry/models.py @@ -0,0 +1,182 @@ +from typing import Literal +from uuid import UUID + +from pydantic import model_validator + +from codealmanac.core.models import CodeAlmanacModel +from codealmanac.services.config.models import HARNESS_MODELS +from codealmanac.services.harnesses.models import HarnessKind + +type TelemetryValue = ( + str + | int + | float + | bool + | None + | list["TelemetryValue"] + | dict[str, "TelemetryValue"] +) + +type CommandName = Literal[ + "init", + "ingest", + "garden", + "sync", + "list", + "search", + "show", + "topics", + "health", + "validate", + "reindex", + "serve", + "tag", + "untag", + "config", + "setup", + "uninstall", + "doctor", + "update", + "jobs", + "automation", +] +type CommandAction = Literal[ + "init", + "ingest", + "garden", + "run", + "status", + "list", + "search", + "show", + "create", + "describe", + "link", + "unlink", + "rename", + "delete", + "health", + "validate", + "reindex", + "serve", + "tag", + "untag", + "get", + "set", + "apply", + "setup", + "uninstall", + "doctor", + "check", + "scheduled", + "logs", + "attach", + "cancel", +] +type DurationBucket = Literal[ + "<250ms", "250ms-1s", "1-10s", "10-60s", "1-5m", "5m+" +] + + +class CliCommandCompletedProperties(CodeAlmanacModel): + command: CommandName + action: CommandAction + outcome: Literal["success", "failed", "crashed", "interrupted"] + exit_code: int + duration_bucket: DurationBucket + interactive: bool + + +class LifecycleRunCompletedProperties(CodeAlmanacModel): + run_kind: Literal["build", "ingest", "garden"] + status: Literal["done", "failed", "cancelled"] + harness: Literal["codex", "claude"] + model: str + duration_bucket: DurationBucket + failure_category: Literal[ + "harness_readiness", + "provider_execution", + "source_preparation", + "mutation_safety", + "wiki_validation", + "indexing", + "internal_error", + ] | None = None + + @model_validator(mode="after") + def validate_lifecycle_contract(self) -> "LifecycleRunCompletedProperties": + if self.model not in HARNESS_MODELS[HarnessKind(self.harness)]: + raise ValueError("model is not controlled for harness") + if self.status == "failed" and self.failure_category is None: + raise ValueError("failed lifecycle events require failure_category") + if self.status != "failed" and self.failure_category is not None: + raise ValueError("only failed lifecycle events have failure_category") + return self + + +class TelemetryIdentity(CodeAlmanacModel): + installation_id: UUID + distinct_id: str + kind: Literal["anonymous", "authenticated"] + + +class TelemetryEvent(CodeAlmanacModel): + event_id: str + name: str + identity: TelemetryIdentity + properties: dict[str, TelemetryValue] + + @model_validator(mode="after") + def only_allow_event_properties(self) -> "TelemetryEvent": + common = { + "cli_version", + "identity_kind", + "os_family", + "os_major", + "architecture", + "python_version", + "$geoip_disable", + } + specific = { + "cli command completed": set(CliCommandCompletedProperties.model_fields), + "lifecycle run completed": set( + LifecycleRunCompletedProperties.model_fields + ), + "$exception": { + "$exception_list", + "$exception_fingerprint", + "$exception_type", + "$exception_message", + "command", + "process_kind", + }, + } + if self.name not in specific: + raise ValueError(f"unsupported telemetry event: {self.name}") + unexpected = set(self.properties) - common - specific[self.name] + if unexpected: + raise ValueError( + f"unsupported {self.name} properties: {', '.join(sorted(unexpected))}" + ) + missing_common = common - set(self.properties) + if missing_common: + raise ValueError( + f"missing common telemetry properties: " + f"{', '.join(sorted(missing_common))}" + ) + event_properties = { + key: self.properties[key] + for key in specific[self.name] + if key in self.properties + } + if self.name == "cli command completed": + CliCommandCompletedProperties.model_validate(event_properties) + elif self.name == "lifecycle run completed": + LifecycleRunCompletedProperties.model_validate(event_properties) + else: + missing = specific[self.name] - set(event_properties) + if missing: + raise ValueError( + f"missing exception properties: {', '.join(sorted(missing))}" + ) + return self diff --git a/src/codealmanac/services/telemetry/ports.py b/src/codealmanac/services/telemetry/ports.py new file mode 100644 index 00000000..22a1f1c5 --- /dev/null +++ b/src/codealmanac/services/telemetry/ports.py @@ -0,0 +1,7 @@ +from typing import Protocol + +from codealmanac.services.telemetry.models import TelemetryEvent + + +class TelemetrySender(Protocol): + def send(self, event: TelemetryEvent) -> None: ... diff --git a/src/codealmanac/services/telemetry/service.py b/src/codealmanac/services/telemetry/service.py new file mode 100644 index 00000000..87b44a65 --- /dev/null +++ b/src/codealmanac/services/telemetry/service.py @@ -0,0 +1,231 @@ +import hashlib +import os +import platform +from collections.abc import Mapping +from types import TracebackType +from uuid import uuid4 + +from codealmanac.services.config.service import ConfigService +from codealmanac.services.runs.models import ( + RunFailureCategory, + RunRecord, + RunSpec, + RunStatus, +) +from codealmanac.services.telemetry.durations import duration_bucket +from codealmanac.services.telemetry.models import ( + CliCommandCompletedProperties, + LifecycleRunCompletedProperties, + TelemetryEvent, + TelemetryIdentity, + TelemetryValue, +) +from codealmanac.services.telemetry.ports import TelemetrySender +from codealmanac.services.telemetry.store import TelemetryIdentityStore + +TRUTHY_ENV_VALUES = frozenset(("1", "true", "yes", "on")) + + +class TelemetryService: + def __init__( + self, + config: ConfigService, + identities: TelemetryIdentityStore, + sender: TelemetrySender, + version: str, + ): + self.config = config + self.identities = identities + self.sender = sender + self.version = version + self._identity: TelemetryIdentity | None = None + self._prepared_policy: bool | None = None + + def identity(self) -> TelemetryIdentity: + if self._identity is None: + self._identity = self.identities.get_or_create() + return self._identity + + def prepare_for_state_removal(self) -> None: + """Freeze policy and identity before a command removes local state.""" + try: + self._prepared_policy = self.enabled() + if self._prepared_policy: + self.identity() + except Exception: + self._prepared_policy = False + return + + def capture( + self, + name: str, + properties: Mapping[str, TelemetryValue], + ) -> None: + try: + if not self.enabled(): + return + self.sender.send(self.event(str(uuid4()), name, properties)) + except Exception: + # Telemetry must never alter product behavior, output, or exit status. + return + + def capture_command(self, properties: CliCommandCompletedProperties) -> None: + self.capture("cli command completed", properties.model_dump()) + + def capture_once( + self, + event_key: str, + name: str, + properties: Mapping[str, TelemetryValue], + ) -> None: + try: + if not self.enabled(): + return + event_id = self.identities.claim_event(event_key) + if event_id is None: + return + self.sender.send(self.event(event_id, name, properties)) + except Exception: + return + + def capture_exception( + self, + error: BaseException, + *, + command: str, + process_kind: str, + ) -> None: + try: + exception_type = type(error).__name__ + frames = safe_frames(error.__traceback__) + fingerprint = exception_fingerprint(exception_type, frames) + self.capture( + "$exception", + { + "$exception_list": [ + { + "mechanism": { + "type": "generic", + "handled": False, + }, + "module": None, + "type": exception_type, + "value": exception_type, + "stacktrace": { + "frames": frames, + "type": "raw", + }, + } + ], + "$exception_fingerprint": fingerprint, + "$exception_type": exception_type, + "$exception_message": exception_type, + "command": command, + "process_kind": process_kind, + }, + ) + except Exception: + # Exception telemetry must never replace the product exception. + return + + def capture_lifecycle( + self, + record: RunRecord, + spec: RunSpec | None, + ) -> None: + try: + if spec is None or record.finished_at is None: + return + started_at = record.started_at or record.created_at + category = None + if record.status == RunStatus.FAILED: + category = ( + record.failure_category or RunFailureCategory.INTERNAL_ERROR + ).value + properties = LifecycleRunCompletedProperties( + run_kind=record.kind.value, + status=record.status.value, + harness=spec.harness.value, + model=spec.model, + duration_bucket=duration_bucket( + max(0.0, (record.finished_at - started_at).total_seconds()) + ), + failure_category=category, + ) + self.capture_once( + f"run:{record.run_id}:terminal", + "lifecycle run completed", + properties.model_dump(exclude_none=True), + ) + except Exception: + return + + def enabled(self) -> bool: + if any( + env_truthy(name) + for name in ("CODEALMANAC_NO_TELEMETRY", "DO_NOT_TRACK", "CI") + ): + return False + if self._prepared_policy is not None: + return self._prepared_policy + return self.config.load_user().telemetry.enabled + + def event( + self, + event_id: str, + name: str, + properties: Mapping[str, TelemetryValue], + ) -> TelemetryEvent: + identity = self.identity() + return TelemetryEvent( + event_id=event_id, + name=name, + identity=identity, + properties={ + **properties, + "cli_version": self.version, + "identity_kind": identity.kind, + "os_family": platform.system().lower(), + "os_major": platform.release().split(".", 1)[0], + "architecture": platform.machine().lower(), + "python_version": ( + f"{platform.python_version_tuple()[0]}." + f"{platform.python_version_tuple()[1]}" + ), + "$geoip_disable": True, + }, + ) + + +def env_truthy(name: str) -> bool: + return os.environ.get(name, "").strip().lower() in TRUTHY_ENV_VALUES + + +def safe_frames(traceback: TracebackType | None) -> list[TelemetryValue]: + frames: list[TelemetryValue] = [] + while traceback is not None: + frame = traceback.tb_frame + module = str(frame.f_globals.get("__name__", "")) + if module == "codealmanac" or module.startswith("codealmanac."): + filename = f"{module.replace('.', '/')}.py" + frames.append( + { + "platform": "python", + "filename": filename, + "module": module, + "function": frame.f_code.co_name, + "lineno": traceback.tb_lineno, + "in_app": True, + } + ) + traceback = traceback.tb_next + return frames[-20:] + + +def exception_fingerprint( + exception_type: str, + frames: list[TelemetryValue], +) -> str: + location = frames[-1] if frames else "no-codealmanac-frame" + source = f"{exception_type}:{location}" + return hashlib.sha256(source.encode()).hexdigest()[:20] diff --git a/src/codealmanac/services/telemetry/store.py b/src/codealmanac/services/telemetry/store.py new file mode 100644 index 00000000..3a174c67 --- /dev/null +++ b/src/codealmanac/services/telemetry/store.py @@ -0,0 +1,63 @@ +from datetime import UTC, datetime +from pathlib import Path +from uuid import UUID, uuid4 + +from codealmanac.database.local import open_local_database +from codealmanac.services.telemetry.models import TelemetryIdentity + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS telemetry_installation ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + installation_id TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS telemetry_delivery ( + event_key TEXT PRIMARY KEY, + event_id TEXT NOT NULL, + claimed_at TEXT NOT NULL +); +""" + + +class TelemetryIdentityStore: + def __init__(self, database_path: Path): + self.database_path = database_path + + def get_or_create(self) -> TelemetryIdentity: + with open_local_database(self.database_path, SCHEMA) as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + "SELECT installation_id FROM telemetry_installation WHERE singleton = 1" + ).fetchone() + if row is None: + installation_id = str(uuid4()) + connection.execute( + """ + INSERT INTO telemetry_installation ( + singleton, installation_id, created_at + ) VALUES (1, ?, ?) + """, + (installation_id, datetime.now(UTC).isoformat()), + ) + connection.commit() + else: + installation_id = str(row["installation_id"]) + return TelemetryIdentity( + installation_id=UUID(installation_id), + distinct_id=installation_id, + kind="anonymous", + ) + + def claim_event(self, event_key: str) -> str | None: + event_id = str(uuid4()) + with open_local_database(self.database_path, SCHEMA) as connection: + result = connection.execute( + """ + INSERT OR IGNORE INTO telemetry_delivery ( + event_key, event_id, claimed_at + ) VALUES (?, ?, ?) + """, + (event_key, event_id, datetime.now(UTC).isoformat()), + ) + connection.commit() + return event_id if result.rowcount == 1 else None diff --git a/src/codealmanac/services/updates/lock.py b/src/codealmanac/services/updates/lock.py index 0d9d2fd0..d6140834 100644 --- a/src/codealmanac/services/updates/lock.py +++ b/src/codealmanac/services/updates/lock.py @@ -1,63 +1,24 @@ -import os -from datetime import UTC, datetime, timedelta +import contextlib from pathlib import Path -from pydantic import ValidationError - -from codealmanac.core.models import CodeAlmanacModel - - -class UpdateLockRecord(CodeAlmanacModel): - pid: int - created_at: datetime +from filelock import FileLock, Timeout class UpdateLockLease: - def __init__(self, path: Path): - self.path = path + def __init__(self, lock: FileLock): + self.lock = lock def release(self) -> None: - self.path.unlink(missing_ok=True) + with contextlib.suppress(OSError): + self.lock.release() class UpdateLockStore: - def acquire( - self, - path: Path, - now: datetime, - stale_after: timedelta, - ) -> UpdateLockLease | None: + def acquire(self, path: Path) -> UpdateLockLease | None: path.parent.mkdir(parents=True, exist_ok=True) - for attempt in range(2): - try: - with path.open("x", encoding="utf-8") as handle: - record = UpdateLockRecord( - pid=os.getpid(), - created_at=now, - ) - handle.write(record.model_dump_json()) - return UpdateLockLease(path) - except FileExistsError: - if attempt > 0 or not stale_lock(path, now, stale_after): - return None - path.unlink(missing_ok=True) - return None - - -def stale_lock(path: Path, now: datetime, stale_after: timedelta) -> bool: - record = read_lock(path) - if record is not None: - age = now - record.created_at - return age > stale_after - try: - modified_at = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC) - except OSError: - return True - return now - modified_at > stale_after - - -def read_lock(path: Path) -> UpdateLockRecord | None: - try: - return UpdateLockRecord.model_validate_json(path.read_text(encoding="utf-8")) - except (OSError, ValidationError, ValueError): - return None + lock = FileLock(str(path)) + try: + lock.acquire(timeout=0) + return UpdateLockLease(lock) + except Timeout: + return None diff --git a/src/codealmanac/services/updates/requests.py b/src/codealmanac/services/updates/requests.py index d98f1b54..f932cb2b 100644 --- a/src/codealmanac/services/updates/requests.py +++ b/src/codealmanac/services/updates/requests.py @@ -1,11 +1,5 @@ -from datetime import datetime, timedelta - -from pydantic import field_validator - from codealmanac.core.models import CodeAlmanacModel -DEFAULT_UPDATE_LOCK_STALE_AFTER = timedelta(minutes=30) - class CheckUpdateRequest(CodeAlmanacModel): pass @@ -13,12 +7,3 @@ class CheckUpdateRequest(CodeAlmanacModel): class RunUpdateRequest(CodeAlmanacModel): scheduled: bool = False - now: datetime | None = None - lock_stale_after: timedelta = DEFAULT_UPDATE_LOCK_STALE_AFTER - - @field_validator("lock_stale_after") - @classmethod - def positive_lock_stale_after(cls, value: timedelta) -> timedelta: - if value.total_seconds() <= 0: - raise ValueError("update lock stale_after must be positive") - return value diff --git a/src/codealmanac/services/updates/service.py b/src/codealmanac/services/updates/service.py index a66c9fea..4cb714b4 100644 --- a/src/codealmanac/services/updates/service.py +++ b/src/codealmanac/services/updates/service.py @@ -1,4 +1,3 @@ -from datetime import UTC, datetime from pathlib import Path from codealmanac.services.updates.activity import active_run_count @@ -61,12 +60,7 @@ def run_scheduled( plan=plan, message=f"scheduled update skipped: {plan.message}", ) - now = request.now or datetime.now(UTC) - lease = self.lock_store.acquire( - self.lock_path, - now, - request.lock_stale_after, - ) + lease = self.lock_store.acquire(self.lock_path) if lease is None: return UpdateResult( status=UpdateStatus.SKIPPED, @@ -88,9 +82,7 @@ def run_scheduled( def run_plan(self, plan: UpdatePlan, smoke: bool) -> UpdateResult: output = self.runner.run(plan.command) status = ( - UpdateStatus.COMPLETED - if output.exit_code == 0 - else UpdateStatus.FAILED + UpdateStatus.COMPLETED if output.exit_code == 0 else UpdateStatus.FAILED ) smoke_results: tuple[UpdateSmokeResult, ...] = () if status == UpdateStatus.COMPLETED and smoke: @@ -120,6 +112,7 @@ def run_smoke(self) -> tuple[UpdateSmokeResult, ...]: ) return tuple(results) + def active_runs_message(active: int) -> str: suffix = "job is" if active == 1 else "jobs are" return f"scheduled update skipped: {active} CodeAlmanac {suffix} active" diff --git a/src/codealmanac/services/viewer/models.py b/src/codealmanac/services/viewer/models.py index ff999bc0..7f09c8bf 100644 --- a/src/codealmanac/services/viewer/models.py +++ b/src/codealmanac/services/viewer/models.py @@ -23,6 +23,8 @@ class ViewerPageSummary(CodeAlmanacModel): summary: str | None path: str topics: tuple[str, ...] + matched_heading: str | None = None + excerpt: str | None = None class ViewerTopicSummary(CodeAlmanacModel): diff --git a/src/codealmanac/services/viewer/projections.py b/src/codealmanac/services/viewer/projections.py index 12f2c424..fed3d098 100644 --- a/src/codealmanac/services/viewer/projections.py +++ b/src/codealmanac/services/viewer/projections.py @@ -84,6 +84,8 @@ def page_summary_from_search( summary=page.summary, path=viewer_relative_path(repository, page.file_path), topics=page.topics, + matched_heading=page.matched_heading, + excerpt=page.excerpt, ) diff --git a/src/codealmanac/services/wiki/sections.py b/src/codealmanac/services/wiki/sections.py new file mode 100644 index 00000000..4f2b0b4b --- /dev/null +++ b/src/codealmanac/services/wiki/sections.py @@ -0,0 +1,89 @@ +from markdown_it import MarkdownIt + +from codealmanac.core.models import CodeAlmanacModel + +MARKDOWN = MarkdownIt("commonmark", {"html": False, "linkify": False}) + + +class WikiSection(CodeAlmanacModel): + section_id: str + ordinal: int + heading_path: tuple[str, ...] + body: str + + @property + def heading(self) -> str: + return " › ".join(self.heading_path) + + +class HeadingBoundary(CodeAlmanacModel): + start_line: int + content_start_line: int + level: int + title: str + + +def project_sections(body: str, page_title: str) -> tuple[WikiSection, ...]: + """Project authored Markdown into stable, source-ordered search sections.""" + boundaries = heading_boundaries(body) + if not boundaries: + return (section(0, (page_title,), body),) + + lines = body.splitlines(keepends=True) + sections: list[WikiSection] = [] + headings: dict[int, str] = {} + + first_heading = boundaries[0] + preamble = "".join(lines[: first_heading.start_line]) + if preamble: + sections.append(section(len(sections), (page_title,), preamble)) + + for index, boundary in enumerate(boundaries): + headings = { + level: title for level, title in headings.items() if level < boundary.level + } + headings[boundary.level] = boundary.title + heading_path = tuple(headings[level] for level in sorted(headings)) + next_start = ( + boundaries[index + 1].start_line + if index + 1 < len(boundaries) + else len(lines) + ) + section_body = "".join(lines[boundary.content_start_line : next_start]) + sections.append(section(len(sections), heading_path, section_body)) + + return tuple(sections) + + +def heading_boundaries(body: str) -> tuple[HeadingBoundary, ...]: + tokens = MARKDOWN.parse(body) + boundaries: list[HeadingBoundary] = [] + for index, token in enumerate(tokens): + if token.type != "heading_open" or token.map is None: + continue + inline = tokens[index + 1] + if inline.type != "inline": + continue + boundaries.append( + HeadingBoundary( + start_line=token.map[0], + content_start_line=token.map[1], + level=int(token.tag.removeprefix("h")), + title=inline.content.strip(), + ) + ) + return tuple(boundaries) + + +def section( + ordinal: int, + heading_path: tuple[str, ...], + body: str, +) -> WikiSection: + return WikiSection( + section_id=f"{ordinal:04d}", + ordinal=ordinal, + heading_path=heading_path, + body=body, + ) + diff --git a/src/codealmanac/workflows/build/service.py b/src/codealmanac/workflows/build/service.py index 485089b8..a25f2909 100644 --- a/src/codealmanac/workflows/build/service.py +++ b/src/codealmanac/workflows/build/service.py @@ -4,7 +4,7 @@ from codealmanac.services.repositories.requests import RegisterRepositoryRequest from codealmanac.services.repositories.roots import RepositoryTarget from codealmanac.services.repositories.service import RepositoriesService -from codealmanac.services.runs.models import RunEventKind +from codealmanac.services.runs.models import RunEventKind, RunFailureCategory from codealmanac.services.wiki.service import WikiService from codealmanac.workflows.build.models import ( BuildPromptPayload, @@ -45,7 +45,10 @@ def execute_started(self, request: StartedBuildRequest) -> BuildResult: run_id=request.run_id, ) ) - try: + with self.operations.failure_phase( + context, + RunFailureCategory.INTERNAL_ERROR, + ): self.operations.record( RecordOperationEventRequest( context=context, @@ -53,30 +56,26 @@ def execute_started(self, request: StartedBuildRequest) -> BuildResult: message="prepared minimal wiki", ) ) - operation = self.operations.execute( - ExecuteOperationRequest( - context=context, - harness=request.harness, - model=request.model, - agent=HarnessAgentKind.BUILD, - prompt=render_build_prompt( - context.repository, - request.guidance, - request.auto_commit, - ), - title=request.title, - success_summary="build completed", - ) + operation_request = ExecuteOperationRequest( + context=context, + harness=request.harness, + model=request.model, + agent=HarnessAgentKind.BUILD, + prompt=render_build_prompt( + context.repository, + request.guidance, + request.auto_commit, + ), + title=request.title, + success_summary="build completed", ) - return BuildResult( - repository=context.repository, - run=operation.run, - harness=operation.harness, - index=operation.index, - ) - except Exception as error: - self.operations.fail(context, error) - raise + operation = self.operations.execute(operation_request) + return BuildResult( + repository=context.repository, + run=operation.run, + harness=operation.harness, + index=operation.index, + ) def register_target( self, diff --git a/src/codealmanac/workflows/garden/service.py b/src/codealmanac/workflows/garden/service.py index 399e5897..b5fba230 100644 --- a/src/codealmanac/workflows/garden/service.py +++ b/src/codealmanac/workflows/garden/service.py @@ -5,7 +5,7 @@ from codealmanac.services.index.models import HealthReport, IndexSummary from codealmanac.services.index.service import IndexService from codealmanac.services.repositories.models import Repository -from codealmanac.services.runs.models import RunEventKind +from codealmanac.services.runs.models import RunEventKind, RunFailureCategory from codealmanac.workflows.garden.models import GardenPromptPayload, GardenResult from codealmanac.workflows.garden.requests import StartedGardenRequest from codealmanac.workflows.operations import ( @@ -36,14 +36,27 @@ def execute_started(self, request: StartedGardenRequest) -> GardenResult: run_id=request.run_id, ) ) - try: + with self.operations.failure_phase( + context, + RunFailureCategory.INDEXING, + ): index_before = self.index.summary(context.repository.repository_id) + + with self.operations.failure_phase( + context, + RunFailureCategory.WIKI_VALIDATION, + ): health_before = self.health.check( HealthCheckRequest( cwd=request.cwd, repository_name=request.repository_name, ) ) + + with self.operations.failure_phase( + context, + RunFailureCategory.INTERNAL_ERROR, + ): self.operations.record( RecordOperationEventRequest( context=context, @@ -51,33 +64,29 @@ def execute_started(self, request: StartedGardenRequest) -> GardenResult: message="prepared garden context", ) ) - operation = self.operations.execute( - ExecuteOperationRequest( - context=context, - harness=request.harness, - model=request.model, - agent=HarnessAgentKind.GARDEN, - prompt=render_garden_prompt( - context.repository, - index_before, - health_before, - request.guidance, - request.auto_commit, - self.manual, - ), - title=request.title, - success_summary="garden completed", - ) - ) - return GardenResult( - run=operation.run, - harness=operation.harness, - index=operation.index, - health_before=health_before, + operation_request = ExecuteOperationRequest( + context=context, + harness=request.harness, + model=request.model, + agent=HarnessAgentKind.GARDEN, + prompt=render_garden_prompt( + context.repository, + index_before, + health_before, + request.guidance, + request.auto_commit, + self.manual, + ), + title=request.title, + success_summary="garden completed", ) - except Exception as error: - self.operations.fail(context, error) - raise + operation = self.operations.execute(operation_request) + return GardenResult( + run=operation.run, + harness=operation.harness, + index=operation.index, + health_before=health_before, + ) def render_garden_prompt( diff --git a/src/codealmanac/workflows/ingest/service.py b/src/codealmanac/workflows/ingest/service.py index 26e4d016..c9444998 100644 --- a/src/codealmanac/workflows/ingest/service.py +++ b/src/codealmanac/workflows/ingest/service.py @@ -1,7 +1,7 @@ from codealmanac.manual import ManualLibrary from codealmanac.services.harnesses.models import HarnessAgentKind from codealmanac.services.repositories.models import Repository -from codealmanac.services.runs.models import RunEventKind +from codealmanac.services.runs.models import RunEventKind, RunFailureCategory from codealmanac.services.sources.models import SourceBrief, SourceRuntime from codealmanac.services.sources.requests import ( InspectSourceRuntimeRequest, @@ -38,10 +38,18 @@ def execute_started(self, request: StartedIngestRequest) -> IngestResult: run_id=run_id, ) ) - try: + with self.operations.failure_phase( + context, + RunFailureCategory.SOURCE_PREPARATION, + ): sources = self.sources.resolve( ResolveSourcesRequest(cwd=request.cwd, inputs=request.inputs) ) + + with self.operations.failure_phase( + context, + RunFailureCategory.INTERNAL_ERROR, + ): self.operations.record( RecordOperationEventRequest( context=context, @@ -49,7 +57,17 @@ def execute_started(self, request: StartedIngestRequest) -> IngestResult: message=f"resolved {len(sources)} {source_word(len(sources))}", ) ) + + with self.operations.failure_phase( + context, + RunFailureCategory.SOURCE_PREPARATION, + ): source_runtime = self.inspect_source_runtime(context.repository, sources) + + with self.operations.failure_phase( + context, + RunFailureCategory.INTERNAL_ERROR, + ): self.operations.record( RecordOperationEventRequest( context=context, @@ -60,34 +78,30 @@ def execute_started(self, request: StartedIngestRequest) -> IngestResult: ), ) ) - operation = self.operations.execute( - ExecuteOperationRequest( - context=context, - harness=request.harness, - model=request.model, - agent=HarnessAgentKind.INGEST, - prompt=render_ingest_prompt( - context.repository, - sources, - source_runtime, - request.guidance, - request.auto_commit, - self.manual, - ), - title=request.title, - success_summary="ingest completed", - ) - ) - return IngestResult( - run=operation.run, - sources=sources, - source_runtime=source_runtime, - harness=operation.harness, - index=operation.index, + operation_request = ExecuteOperationRequest( + context=context, + harness=request.harness, + model=request.model, + agent=HarnessAgentKind.INGEST, + prompt=render_ingest_prompt( + context.repository, + sources, + source_runtime, + request.guidance, + request.auto_commit, + self.manual, + ), + title=request.title, + success_summary="ingest completed", ) - except Exception as error: - self.operations.fail(context, error) - raise + operation = self.operations.execute(operation_request) + return IngestResult( + run=operation.run, + sources=sources, + source_runtime=source_runtime, + harness=operation.harness, + index=operation.index, + ) def inspect_source_runtime( self, diff --git a/src/codealmanac/workflows/operations/service.py b/src/codealmanac/workflows/operations/service.py index 079c8bce..7a3b54ff 100644 --- a/src/codealmanac/workflows/operations/service.py +++ b/src/codealmanac/workflows/operations/service.py @@ -1,14 +1,23 @@ -from contextlib import suppress +from collections.abc import Iterator +from contextlib import contextmanager, suppress from pathlib import Path +from codealmanac.core.errors import error_summary from codealmanac.services.harnesses.models import HarnessEvent, HarnessRunResult from codealmanac.services.harnesses.requests import RunHarnessRequest -from codealmanac.services.harnesses.service import HarnessesService +from codealmanac.services.harnesses.service import ( + HarnessesService, + HarnessEventSinkFailed, +) from codealmanac.services.health.service import HealthService from codealmanac.services.index.service import IndexService from codealmanac.services.repositories.models import Repository, RepositoryName from codealmanac.services.repositories.service import RepositoriesService -from codealmanac.services.runs.models import RunEventKind, RunStatus +from codealmanac.services.runs.models import ( + RunEventKind, + RunFailureCategory, + RunStatus, +) from codealmanac.services.runs.requests import ( FinishRunRequest, MarkRunRunningRequest, @@ -17,7 +26,6 @@ ) from codealmanac.services.runs.service import RunsService from codealmanac.workflows.operations.harness import ( - first_line, harness_events, harness_run_event_kind, should_record_harness_event, @@ -76,38 +84,96 @@ def record_live_event(event: HarnessEvent) -> None: emitted_events.append(event) self.record_harness_event(request.context, event) - harness = self.harnesses.run( - RunHarnessRequest( + with self.failure_phase( + request.context, + RunFailureCategory.INTERNAL_ERROR, + ): + harness_request = RunHarnessRequest( kind=request.harness, model=request.model, agent=request.agent, cwd=repository.root_path, prompt=request.prompt, title=request.title, - ), - on_event=record_live_event, - ) - self.record_harness_transcript(request.context, harness) - if len(emitted_events) == 0: - self.record_harness_events(request.context, harness) - validate_harness_result(harness) - index = self.index.ensure_fresh(repository.repository_id) - self.health.ensure_valid(repository) - finished = self.runs.finish( - FinishRunRequest( - run_id=request.context.run_id, - status=RunStatus.DONE, - summary=harness.summary or request.success_summary, ) - ) + + try: + self.harnesses.ensure_ready(request.harness) + except Exception as error: + self.fail( + request.context, + error, + RunFailureCategory.HARNESS_READINESS, + ) + raise + + try: + harness = self.harnesses.run_ready( + harness_request, + on_event=record_live_event, + ) + except HarnessEventSinkFailed as error: + self.fail( + request.context, + error.error, + RunFailureCategory.INTERNAL_ERROR, + ) + raise error.error from error + except Exception as error: + self.fail( + request.context, + error, + RunFailureCategory.PROVIDER_EXECUTION, + ) + raise + + with self.failure_phase( + request.context, + RunFailureCategory.INTERNAL_ERROR, + ): + self.record_harness_transcript(request.context, harness) + if len(emitted_events) == 0: + self.record_harness_events(request.context, harness) + + with self.failure_phase( + request.context, + RunFailureCategory.PROVIDER_EXECUTION, + ): + validate_harness_result(harness) + + with self.failure_phase(request.context, RunFailureCategory.INDEXING): + index = self.index.ensure_fresh(repository.repository_id) + + with self.failure_phase( + request.context, + RunFailureCategory.WIKI_VALIDATION, + ): + self.health.ensure_valid(repository) + + with self.failure_phase( + request.context, + RunFailureCategory.INTERNAL_ERROR, + ): + finished = self.runs.finish( + FinishRunRequest( + run_id=request.context.run_id, + status=RunStatus.DONE, + summary=harness.summary or request.success_summary, + ) + ) return OperationResult( run=finished, harness=harness, index=index, ) - def fail(self, context: OperationContext, error: Exception) -> None: - message = first_line(str(error)) or error.__class__.__name__ + def fail( + self, + context: OperationContext, + error: Exception, + category: RunFailureCategory, + ) -> None: + message = error_summary(error) with suppress(Exception): self.record( RecordOperationEventRequest( @@ -116,14 +182,28 @@ def fail(self, context: OperationContext, error: Exception) -> None: message=message, ) ) + with suppress(Exception): self.runs.finish( FinishRunRequest( run_id=context.run_id, status=RunStatus.FAILED, error=message, + failure_category=category, ) ) + @contextmanager + def failure_phase( + self, + context: OperationContext, + category: RunFailureCategory, + ) -> Iterator[None]: + try: + yield + except Exception as error: + self.fail(context, error, category) + raise + def resolve_repository( self, cwd: Path, diff --git a/src/codealmanac/workflows/run_queue/service.py b/src/codealmanac/workflows/run_queue/service.py index 71eeb242..055ffb5a 100644 --- a/src/codealmanac/workflows/run_queue/service.py +++ b/src/codealmanac/workflows/run_queue/service.py @@ -16,6 +16,7 @@ SpawnRunWorkerRequest, ) from codealmanac.services.runs.service import RunsService +from codealmanac.services.telemetry.service import TelemetryService from codealmanac.workflows.build.requests import BuildRequest from codealmanac.workflows.build.service import BuildWorkflow from codealmanac.workflows.garden.requests import ( @@ -63,6 +64,7 @@ def __init__( executor: RunExecutor, executor_spawner: RunExecutorSpawner, cancellation: RunCancellation, + telemetry: TelemetryService, ): self.repositories = repositories self.runs = runs @@ -72,7 +74,7 @@ def __init__( self.spawner = spawner self.executor = executor self.cancellation = cancellation - self.worker = RunQueueWorker(runs, executor_spawner) + self.worker = RunQueueWorker(runs, executor_spawner, telemetry) def queue_build(self, request: BuildRequest) -> RunRecord: repository = self.build.prepare(request) diff --git a/src/codealmanac/workflows/run_queue/worker.py b/src/codealmanac/workflows/run_queue/worker.py index 0908ffe9..caeee3ae 100644 --- a/src/codealmanac/workflows/run_queue/worker.py +++ b/src/codealmanac/workflows/run_queue/worker.py @@ -5,17 +5,21 @@ TERMINAL_RUN_STATUSES, QueuedRun, RunEventKind, + RunFailureCategory, RunQueueDrainResult, RunRecord, RunStatus, + RunWorkerIdleHandoffOutcome, ) from codealmanac.services.runs.requests import ( AcquireRunWorkerLockRequest, FinishRunRequest, RecordRunEventRequest, + ReleaseRunWorkerIfIdleRequest, ShowRunRequest, ) from codealmanac.services.runs.service import RunsService +from codealmanac.services.telemetry.service import TelemetryService from codealmanac.workflows.run_queue.ports import RunExecutorSpawner from codealmanac.workflows.run_queue.requests import ( DrainRunQueueRequest, @@ -27,9 +31,15 @@ class RunQueueWorker: - def __init__(self, runs: RunsService, executors: RunExecutorSpawner): + def __init__( + self, + runs: RunsService, + executors: RunExecutorSpawner, + telemetry: TelemetryService, + ): self.runs = runs self.executors = executors + self.telemetry = telemetry def drain(self, request: DrainRunQueueRequest) -> RunQueueDrainResult: lease = self.runs.acquire_worker_lock( @@ -47,6 +57,11 @@ def drain(self, request: DrainRunQueueRequest) -> RunQueueDrainResult: while request.max_runs is None or len(processed) < request.max_runs: queued = self.runs.next_queued() if queued is None: + outcome = self.runs.release_worker_if_idle( + ReleaseRunWorkerIfIdleRequest(owner=lease.owner) + ) + if outcome == RunWorkerIdleHandoffOutcome.WORK_AVAILABLE: + continue break record = self.run_queued(queued) processed.append(record) @@ -68,6 +83,11 @@ def run_queued(self, queued: QueuedRun) -> RunRecord: return self.reconcile_exit(queued.record.run_id, exit_code) except Exception as error: record = self.runs.show(ShowRunRequest(run_id=queued.record.run_id)) + self.telemetry.capture_exception( + error, + command=record.kind.value, + process_kind="worker", + ) if record.status in TERMINAL_RUN_STATUSES: return record return self.fail_run(record, error_summary(error)) @@ -109,5 +129,6 @@ def fail_run(self, record: RunRecord, error: str) -> RunRecord: run_id=record.run_id, status=RunStatus.FAILED, error=error, + failure_category=RunFailureCategory.INTERNAL_ERROR, ) ) diff --git a/tests/conftest.py b/tests/conftest.py index a7df6b69..b3fe78a9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,6 +63,13 @@ def spawn(self, request: SpawnRunExecutorRequest) -> InlineRunExecutorProcess: return InlineRunExecutorProcess(self.executor, request.run_id) +@pytest.fixture(autouse=True) +def disable_external_telemetry_during_tests( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("CODEALMANAC_NO_TELEMETRY", "1") + + def bind_inline_executor( app: CodeAlmanac, spawner: InlineRunExecutorSpawner, diff --git a/tests/test_architecture.py b/tests/test_architecture.py index f09a32cc..f1bf4574 100644 --- a/tests/test_architecture.py +++ b/tests/test_architecture.py @@ -147,7 +147,7 @@ def test_index_read_views_are_split_by_query_family(): assert expected <= {path.name for path in index_root.glob("*.py")} assert oversized == [] - assert "fts_pages MATCH" in (index_root / "search_views.py").read_text( + assert "fts_sections MATCH" in (index_root / "search_views.py").read_text( encoding="utf-8" ) assert "PageView(" in (index_root / "page_views.py").read_text(encoding="utf-8") @@ -199,7 +199,7 @@ def test_index_store_keeps_write_side_responsibilities_split(): assert "def stored_signature" in projection assert "def replace_documents" in projection assert "INSERT INTO pages" in projection - assert "DELETE FROM fts_pages" in projection + assert "DELETE FROM fts_sections" in projection assert "load_page_document" not in projection assert "SCHEMA_DDL" not in projection @@ -756,6 +756,10 @@ def test_cli_admin_render_stays_split_by_output_family(): "config.py": ("ConfigSetResult", "def render_config_set("), "diagnostics.py": ("DoctorReport", "def render_doctor("), "jobs.py": ("RunRecord", "def render_runs("), + "setup/background_items.py": ( + "BackgroundItemNotice", + "def render_background_item_notice(", + ), "setup/result.py": ("SetupResult", "def render_setup_result("), "setup/uninstall.py": ("UninstallResult", "def render_uninstall_text("), "setup/screens.py": ("SetupChoiceScreen", "def render_setup_choice_screen("), diff --git a/tests/test_build_workflow.py b/tests/test_build_workflow.py index 40444dbe..e2897640 100644 --- a/tests/test_build_workflow.py +++ b/tests/test_build_workflow.py @@ -15,9 +15,12 @@ from codealmanac.core.errors import ( AlreadyExists, NoRepositorySelected, + NotFoundError, ) from codealmanac.services.harnesses.models import ( HarnessAgentKind, + HarnessEvent, + HarnessEventKind, HarnessKind, HarnessReadiness, HarnessRunResult, @@ -28,7 +31,8 @@ from codealmanac.services.repositories.models import RepositoryState from codealmanac.services.repositories.requests import RegisterRepositoryRequest from codealmanac.services.repositories.roots import is_initialized_almanac_root -from codealmanac.services.runs.models import RunKind, RunStatus +from codealmanac.services.runs.models import RunFailureCategory, RunKind, RunStatus +from codealmanac.services.runs.requests import ShowRunRequest from codealmanac.settings import AppConfig from codealmanac.workflows.build.requests import BuildRequest, StartedBuildRequest from codealmanac.workflows.run_queue.requests import DrainRunQueueRequest @@ -229,6 +233,33 @@ def run(self, request: RunHarnessRequest, on_event=None) -> HarnessRunResult: raise AssertionError("a broken harness never runs") +class ExplodingHarnessAdapter: + kind = HarnessKind.CODEX + + def __init__(self, error: Exception): + self.error = error + + def check(self) -> HarnessReadiness: + return HarnessReadiness( + kind=self.kind, + available=True, + message="codex ready", + ) + + def run(self, request: RunHarnessRequest, on_event=None) -> HarnessRunResult: + raise self.error + + +class EventEmittingHarnessAdapter(ExplodingHarnessAdapter): + def __init__(self): + super().__init__(AssertionError("event sink should stop the run")) + + def run(self, request: RunHarnessRequest, on_event=None) -> HarnessRunResult: + assert on_event is not None + on_event(HarnessEvent(kind=HarnessEventKind.TEXT, message="working")) + raise self.error + + def test_queue_build_records_run_before_worker_checks_runner( tmp_path: Path, isolated_home: Path, @@ -287,11 +318,96 @@ def test_worker_fails_build_when_selected_runner_is_not_ready( assert len(drained.processed) == 1 assert drained.processed[0].run_id == run.run_id assert drained.processed[0].status == RunStatus.FAILED + assert ( + drained.processed[0].failure_category + == RunFailureCategory.HARNESS_READINESS + ) assert drained.processed[0].error is not None assert "harness codex is not available" in drained.processed[0].error assert adapter.requests == [] +@pytest.mark.parametrize( + "provider_error", + ( + RuntimeError("provider transport exploded"), + NotFoundError("provider resource", "missing"), + ), +) +def test_build_classifies_adapter_invocation_errors_as_provider_execution( + tmp_path: Path, + isolated_home: Path, + provider_error: Exception, +) -> None: + repo = tmp_path / "repo" + repo.mkdir() + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db"), + harness_adapters=(ExplodingHarnessAdapter(provider_error),), + ) + queued = app.workflows.queue.queue_build( + BuildRequest( + path=repo, + harness=HarnessKind.CODEX, + model="gpt-5.5", + name="repo", + ) + ) + + with pytest.raises(type(provider_error)): + app.workflows.build.execute_started( + StartedBuildRequest( + run_id=queued.run_id, + harness=HarnessKind.CODEX, + model="gpt-5.5", + ) + ) + + stored = app.runs.show(ShowRunRequest(run_id=queued.run_id)) + assert stored.status == RunStatus.FAILED + assert stored.failure_category == RunFailureCategory.PROVIDER_EXECUTION + + +def test_build_classifies_live_event_store_failure_as_internal( + tmp_path: Path, + isolated_home: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = tmp_path / "repo" + repo.mkdir() + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db"), + harness_adapters=(EventEmittingHarnessAdapter(),), + ) + queued = app.workflows.queue.queue_build( + BuildRequest( + path=repo, + harness=HarnessKind.CODEX, + model="gpt-5.5", + name="repo", + ) + ) + sink_error = OSError("run-event store unavailable") + monkeypatch.setattr( + app.workflows.build.operations, + "record_harness_event", + lambda context, event: (_ for _ in ()).throw(sink_error), + ) + + with pytest.raises(OSError, match="run-event store unavailable"): + app.workflows.build.execute_started( + StartedBuildRequest( + run_id=queued.run_id, + harness=HarnessKind.CODEX, + model="gpt-5.5", + ) + ) + + stored = app.runs.show(ShowRunRequest(run_id=queued.run_id)) + assert stored.status == RunStatus.FAILED + assert stored.failure_category == RunFailureCategory.INTERNAL_ERROR + + def test_repository_states_report_missing_paths( tmp_path: Path, isolated_home: Path, diff --git a/tests/test_cli.py b/tests/test_cli.py index 58ea68c1..6b5687d0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ import json import shutil import subprocess +import sys from datetime import UTC, datetime, timedelta from pathlib import Path @@ -31,12 +32,14 @@ ) from codealmanac.services.harnesses.requests import RunHarnessRequest from codealmanac.services.runs.models import ( + RunAttachUpdate, RunEventKind, RunKind, RunStatus, RunWorkerSpawnResult, ) from codealmanac.services.runs.requests import ( + AttachRunRequest, FinishRunRequest, ListRunsRequest, MarkRunRunningRequest, @@ -298,10 +301,14 @@ def test_cli_init_creates_wiki_and_prints_name( captured = capsys.readouterr() assert exit_code == 0 - assert "◆ build queued: build-" in captured.out - assert "repo: my-repo" in captured.out - assert "follow: codealmanac jobs attach build-" in captured.out - assert "◇ every codebase deserves a biography" in captured.out + assert "◆ Building your CodeAlmanac in the background" in captured.out + assert "Follow progress in the CodeAlmanac viewer" in captured.out + assert "codealmanac serve" in captured.out + assert "Then select Jobs in the sidebar." in captured.out + assert "╭" in captured.out + assert "╰" in captured.out + assert "codealmanac jobs attach build-" in captured.out + assert "· Repo: my-repo" in captured.out assert captured.err == "" assert (repo / "almanac/README.md").is_file() assert (repo / "almanac/topics.yaml").is_file() @@ -358,6 +365,9 @@ def test_cli_setup_and_uninstall_codex_instructions( assert "Codex" in captured.out assert "Product updates" in captured.out assert "auto-update on" in captured.out + assert "macOS background items" in captured.out + assert "may have shown 3 “Background Items Added” notifications" in captured.out + assert "CodeAlmanac's Sync, Garden, and Update schedules" in captured.out assert "Agent change handling" in captured.out assert "agents may create almanac: commits" in captured.out assert "Next steps" in captured.out @@ -476,7 +486,20 @@ def test_cli_setup_interactive_choices_can_disable_update_and_commits( "codealmanac.cli.dispatch.setup_tui.supports_interactive_setup", lambda: True, ) - keys = iter(("\r", "\r", "\r", "\r", "\x1b[C", "\r", "\x1b[C", "\r")) + keys = iter( + ( + "\r", + "\r", + "\r", + "\r", + "\x1b[C", + "\r", + "\x1b[C", + "\r", + "\x1b[C", + "\r", + ) + ) monkeypatch.setattr( "codealmanac.cli.dispatch.setup_tui.read_setup_key", lambda: next(keys), @@ -485,12 +508,15 @@ def test_cli_setup_interactive_choices_can_disable_update_and_commits( assert main(["setup", "--target", "codex"]) == 0 output = capsys.readouterr() - assert "[1/6]" in output.out - assert "[2/6]" in output.out - assert "[3/6]" in output.out - assert "[4/6]" in output.out - assert "[5/6]" in output.out - assert "[6/6]" in output.out + assert "[1/7]" in output.out + assert "[2/7]" in output.out + assert "[3/7]" in output.out + assert "[4/7]" in output.out + assert "[5/7]" in output.out + assert "[6/7]" in output.out + assert "[7/7]" in output.out + assert output.out.index("AI runner") < output.out.index("Runner model") + assert output.out.index("Runner model") < output.out.index("Agent instructions") assert ( "Add CodeAlmanac instructions to your AGENTS.md / CLAUDE.md:" in output.out ) @@ -498,13 +524,26 @@ def test_cli_setup_interactive_choices_can_disable_update_and_commits( assert "almanac: update wiki context" in output.out assert "almanac/architecture/indexing.md" in output.out assert "How should your wikis be updated?" in output.out + assert "Sync learns from recent sessions" in output.out + assert "Garden improves your wiki" in output.out + assert "macOS heads-up" in output.out + assert "adds 3 CodeAlmanac background tasks: Sync, Garden, and Update" in output.out + assert "They are expected and all from CodeAlmanac" in output.out assert "[b] Codex + Claude" not in output.out assert "sync quiet agent sessions" not in output.out assert "install local scheduled updater" not in output.out assert "Product updates" in output.out assert "auto-update off" in output.out + assert "may have shown 2 “Background Items Added” notifications" in output.out + assert "CodeAlmanac's Sync and Garden schedules" in output.out assert "Agent change handling" in output.out assert "agents leave wiki edits in the worktree for review" in output.out + assert "Help improve CodeAlmanac" in output.out + assert "Yes," in output.out + assert "improve" in output.out + assert "Recommended" in output.out + assert "No" in output.out + assert "thanks" in output.out assert tuple(job.task for job in scheduler.installed) == ( AutomationTask.SYNC, AutomationTask.GARDEN, @@ -515,6 +554,57 @@ def test_cli_setup_interactive_choices_can_disable_update_and_commits( assert "auto_commit = false\n" in config_text assert 'default = "codex"\n' in config_text assert 'model = "gpt-5.5"\n' in config_text + assert "[telemetry]\nenabled = false\n" in config_text + + +def test_cli_setup_yes_enables_telemetry_and_flag_disables_it( + isolated_home: Path, + monkeypatch, +) -> None: + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db"), + scheduler=CliSchedulerAdapter(), + harness_adapters=(CliNoopHarnessAdapter(),), + ) + monkeypatch.setattr("codealmanac.cli.main.create_app", lambda: app) + + assert main(["setup", "--yes", "--target", "codex"]) == 0 + assert app.config.load_user().telemetry.enabled is True + + assert main( + ["setup", "--yes", "--target", "codex", "--no-telemetry"] + ) == 0 + assert app.config.load_user().telemetry.enabled is False + + +def test_cli_setup_no_telemetry_flag_forces_opt_out_without_permission_screen( + isolated_home: Path, + monkeypatch, + capsys, +) -> None: + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db"), + scheduler=CliSchedulerAdapter(), + harness_adapters=(CliNoopHarnessAdapter(),), + ) + monkeypatch.setattr("codealmanac.cli.main.create_app", lambda: app) + monkeypatch.setattr( + "codealmanac.cli.dispatch.setup_tui.supports_interactive_setup", + lambda: True, + ) + keys = iter(("\r", "\r", "\r", "\r", "\r", "\r")) + monkeypatch.setattr( + "codealmanac.cli.dispatch.setup_tui.read_setup_key", + lambda: next(keys), + ) + + assert main(["setup", "--target", "codex", "--no-telemetry"]) == 0 + + output = capsys.readouterr() + assert "[1/6]" in output.out + assert "[6/6]" in output.out + assert "Help improve CodeAlmanac" not in output.out + assert app.config.load_user().telemetry.enabled is False def test_cli_setup_fails_when_default_runner_is_unavailable( @@ -878,7 +968,7 @@ def test_cli_init_and_reindex_commands( assert main(["init", str(repo)]) == 0 init_output = capsys.readouterr() - assert "◆ build queued: build-" in init_output.out + assert "◆ Building your CodeAlmanac in the background" in init_output.out assert not (repo / "almanac/index.db").exists() (repo / "almanac/note.md").write_text( @@ -1116,6 +1206,77 @@ def test_cli_help_includes_serve(capsys): assert "config" in output.out +@pytest.mark.parametrize( + ("extra_args", "should_open"), + (((), True), (("--no-open",), False)), +) +def test_cli_serve_opens_viewer_after_server_is_ready( + viewer_repo, + monkeypatch, + capsys, + extra_args: tuple[str, ...], + should_open: bool, +): + import uvicorn + + from codealmanac.cli.dispatch import serve as serve_dispatch + + repo, app = viewer_repo + monkeypatch.chdir(repo) + opened_urls: list[str] = [] + browser_threads = [] + + class FakeConfig: + def __init__(self, server_app, **options): + self.server_app = server_app + self.options = options + + class FakeThread: + def __init__(self, *, target, args, name, daemon): + self.target = target + self.args = args + self.name = name + self.daemon = daemon + browser_threads.append(self) + + def start(self): + return None + + def run(self): + self.target(*self.args) + + class FakeServer: + def __init__(self, config): + self.config = config + self.started = False + self.should_exit = False + + def run(self): + assert opened_urls == [] + self.started = True + for thread in browser_threads: + thread.run() + raise KeyboardInterrupt + + monkeypatch.setattr(uvicorn, "Config", FakeConfig) + monkeypatch.setattr(uvicorn, "Server", FakeServer) + monkeypatch.setattr(serve_dispatch.threading, "Thread", FakeThread) + monkeypatch.setattr( + serve_dispatch.webbrowser, + "open_new_tab", + opened_urls.append, + ) + args = build_parser().parse_args(("serve", *extra_args)) + + assert serve_dispatch.run_serve(app, args) == 0 + + output = capsys.readouterr() + assert output.out == "codealmanac viewer: http://127.0.0.1:3927\n" + assert args.no_open is not should_open + assert opened_urls == (["http://127.0.0.1:3927"] if should_open else []) + assert len(browser_threads) == int(should_open) + + def test_cli_ingest_queues_run_with_selected_harness( tmp_path: Path, isolated_home: Path, @@ -1157,8 +1318,8 @@ def test_cli_ingest_queues_run_with_selected_harness( output = capsys.readouterr() run = app.runs.list(ListRunsRequest(repository_name="repo"))[0] spec = app.runs.read_spec(ReadRunSpecRequest(run_id=run.run_id)) - assert f"ingest queued: {run.run_id}" in output.out - assert "follow: codealmanac jobs attach" in output.out + assert "◆ Adding knowledge to your CodeAlmanac in the background" in output.out + assert f"codealmanac jobs attach {run.run_id}" in output.out assert run.status == RunStatus.QUEUED assert spec is not None assert spec.harness == HarnessKind.CODEX @@ -1202,7 +1363,8 @@ def test_cli_ingest_uses_configured_default_harness( output = capsys.readouterr() run = app.runs.list(ListRunsRequest(repository_name="repo"))[0] spec = app.runs.read_spec(ReadRunSpecRequest(run_id=run.run_id)) - assert f"ingest queued: {run.run_id}" in output.out + assert "◆ Adding knowledge to your CodeAlmanac in the background" in output.out + assert f"Job: {run.run_id} · Repo: repo" in output.out assert spec is not None assert spec.harness == HarnessKind.CODEX assert adapter.requests == [] @@ -1283,7 +1445,8 @@ def test_cli_garden_queues_run_with_selected_harness( output = capsys.readouterr() run = app.runs.list(ListRunsRequest(repository_name="repo"))[0] spec = app.runs.read_spec(ReadRunSpecRequest(run_id=run.run_id)) - assert f"garden queued: {run.run_id}" in output.out + assert "◆ Improving your CodeAlmanac in the background" in output.out + assert f"Job: {run.run_id} · Repo: repo" in output.out assert run.status == RunStatus.QUEUED assert spec is not None assert spec.harness == HarnessKind.CODEX @@ -1317,9 +1480,12 @@ def test_cli_garden_plain_output( output = capsys.readouterr() run = app.runs.list(ListRunsRequest(repository_name="repo"))[0] - assert f"garden queued: {run.run_id}" in output.out - assert f"follow: codealmanac jobs attach {run.run_id}" in output.out - assert "worker: pid 5151" in output.out + assert "◆ Improving your CodeAlmanac in the background" in output.out + assert "│ Follow progress in the CodeAlmanac viewer" in output.out + assert "│ codealmanac serve" in output.out + assert "│ Then select Jobs in the sidebar." in output.out + assert f"codealmanac jobs attach {run.run_id}" in output.out + assert f"Job: {run.run_id} · Repo: repo" in output.out assert run.kind == RunKind.GARDEN assert harness.requests == [] assert spawner.requests == [SpawnRunWorkerRequest(cwd=repo)] @@ -1613,6 +1779,94 @@ def test_cli_jobs_inspects_local_run_records( assert '"status": "cancelled"' in cancel_json_output.out +@pytest.mark.parametrize("terminal", [False, True]) +def test_cli_jobs_attach_ctrl_c_detaches_without_mutating_run( + tmp_path: Path, + default_cli_app, + monkeypatch, + capsys, + terminal: bool, +): + repo = tmp_path / "repo" + repo.mkdir() + repository = initialize_repository(default_cli_app, path=repo) + record = default_cli_app.runs.start( + StartRunRequest( + repository_id=repository.repository_id, + kind=RunKind.BUILD, + ) + ) + + def interrupt_attach(_request): + def interrupted_updates(): + yield from () + raise KeyboardInterrupt + + return interrupted_updates() + + monkeypatch.chdir(repo) + monkeypatch.setattr(default_cli_app.runs, "stream_attach", interrupt_attach) + monkeypatch.setattr(sys.stdout, "isatty", lambda: terminal) + + assert main(["jobs", "attach", record.run_id]) == 130 + + output = capsys.readouterr() + assert output.out == ( + "detached — job continues in background\n" + f"cancel: codealmanac jobs cancel {record.run_id}\n" + ) + assert output.err == "" + assert default_cli_app.runs.show( + ShowRunRequest(run_id=record.run_id) + ).status == RunStatus.QUEUED + + +def test_cli_jobs_attach_ctrl_c_keeps_json_stream_clean( + tmp_path: Path, + default_cli_app, + monkeypatch, + capsys, +): + repo = tmp_path / "repo" + repo.mkdir() + repository = initialize_repository(default_cli_app, path=repo) + record = default_cli_app.runs.start( + StartRunRequest( + repository_id=repository.repository_id, + kind=RunKind.BUILD, + ) + ) + + snapshot = default_cli_app.runs.attach(AttachRunRequest(run_id=record.run_id)) + + def interrupt_attach(_request): + def interrupted_updates(): + yield RunAttachUpdate( + record=snapshot.record, + events=snapshot.events, + terminal=snapshot.terminal, + ) + raise KeyboardInterrupt + + return interrupted_updates() + + monkeypatch.chdir(repo) + monkeypatch.setattr(default_cli_app.runs, "stream_attach", interrupt_attach) + + assert main(["jobs", "attach", record.run_id, "--json"]) == 130 + + output = capsys.readouterr() + json_lines = output.out.splitlines() + assert len(json_lines) == 1 + update = json.loads(json_lines[0]) + assert update["record"]["run_id"] == record.run_id + assert update["terminal"] is False + assert output.err == "" + assert default_cli_app.runs.show( + ShowRunRequest(run_id=record.run_id) + ).status == RunStatus.QUEUED + + def test_cli_jobs_rejects_path_shaped_run_ids(capsys): assert main(["jobs", "show", "../secret"]) == 1 diff --git a/tests/test_cli_telemetry.py b/tests/test_cli_telemetry.py new file mode 100644 index 00000000..7f59709b --- /dev/null +++ b/tests/test_cli_telemetry.py @@ -0,0 +1,351 @@ +from argparse import Namespace +from pathlib import Path + +import pytest + +from codealmanac.app import create_app +from codealmanac.cli.main import main +from codealmanac.cli.telemetry import command_action, duration_bucket +from codealmanac.services.automation.models import ScheduledJob, ScheduledJobStatus +from codealmanac.services.setup.models import ( + PackageUninstallResult, + PackageUninstallStatus, +) +from codealmanac.services.telemetry.models import TelemetryEvent +from codealmanac.services.updates.models import UpdateInstallMethod +from codealmanac.settings import AppConfig + + +@pytest.fixture(autouse=True) +def enable_test_telemetry(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ("CODEALMANAC_NO_TELEMETRY", "DO_NOT_TRACK", "CI"): + monkeypatch.delenv(name, raising=False) + + +class RecordingSender: + def __init__(self): + self.events: list[TelemetryEvent] = [] + + def send(self, event: TelemetryEvent) -> None: + self.events.append(event) + + +class SkippedPackageUninstaller: + def uninstall(self) -> PackageUninstallResult: + return PackageUninstallResult( + status=PackageUninstallStatus.SKIPPED, + method=UpdateInstallMethod.UNKNOWN, + message="test package removal skipped", + ) + + +class SkippedScheduler: + def install(self, _job: ScheduledJob) -> ScheduledJobStatus: + raise AssertionError("install is not expected during uninstall") + + def uninstall(self, _job: ScheduledJob) -> bool: + return False + + def status(self, _job: ScheduledJob) -> ScheduledJobStatus: + raise AssertionError("status is not expected during uninstall") + + +@pytest.mark.parametrize( + ("namespace", "expected"), + ( + (Namespace(command="search", query="private query"), ("search", "search")), + (Namespace(command="sync", sync_command=None), ("sync", "run")), + (Namespace(command="sync", sync_command="status"), ("sync", "status")), + (Namespace(command="topics", topic_command=None), ("topics", "list")), + (Namespace(command="topics", topic_command="delete"), ("topics", "delete")), + (Namespace(command="config", config_command="set"), ("config", "set")), + (Namespace(command="jobs", jobs_command=None), ("jobs", "list")), + (Namespace(command="jobs", jobs_command="attach"), ("jobs", "attach")), + ( + Namespace(command="automation", automation_command="status"), + ("automation", "status"), + ), + ( + Namespace(command="update", check=False, scheduled=False), + ("update", "run"), + ), + ( + Namespace(command="update", check=True, scheduled=False), + ("update", "check"), + ), + ( + Namespace(command="update", check=False, scheduled=True), + ("update", "scheduled"), + ), + ), +) +def test_command_action_uses_only_controlled_parser_fields( + namespace: Namespace, + expected: tuple[str, str], +) -> None: + assert command_action(namespace) == expected + + +def test_hidden_commands_are_not_product_commands() -> None: + assert command_action(Namespace(command="__run-worker")) is None + + +@pytest.mark.parametrize( + ("seconds", "expected"), + ( + (0.249, "<250ms"), + (0.25, "250ms-1s"), + (1, "1-10s"), + (10, "10-60s"), + (60, "1-5m"), + (300, "5m+"), + ), +) +def test_duration_buckets(seconds: float, expected: str) -> None: + assert duration_bucket(seconds) == expected + + +def test_main_captures_successful_command_without_arguments( + tmp_path: Path, + monkeypatch, +) -> None: + sender = RecordingSender() + app = create_app( + AppConfig(database_path=tmp_path / "codealmanac.db"), + telemetry_sender=sender, + ) + monkeypatch.setattr("codealmanac.cli.main.create_app", lambda: app) + + assert main(["config", "list", "--json"]) == 0 + + event = sender.events[0] + assert event.name == "cli command completed" + assert event.properties["command"] == "config" + assert event.properties["action"] == "list" + assert event.properties["outcome"] == "success" + assert event.properties["exit_code"] == 0 + assert "argv" not in event.properties + assert "json" not in event.properties + + +def test_main_captures_handled_failure_without_exception( + tmp_path: Path, + monkeypatch, +) -> None: + sender = RecordingSender() + app = create_app( + AppConfig(database_path=tmp_path / "codealmanac.db"), + telemetry_sender=sender, + ) + monkeypatch.setattr("codealmanac.cli.main.create_app", lambda: app) + + assert main(["show", "private-page-name"]) == 1 + + assert [event.name for event in sender.events] == ["cli command completed"] + assert sender.events[0].properties["outcome"] == "failed" + + +def test_current_config_opt_out_command_emits_no_event( + tmp_path: Path, + monkeypatch, +) -> None: + sender = RecordingSender() + app = create_app( + AppConfig(database_path=tmp_path / "codealmanac.db"), + telemetry_sender=sender, + ) + monkeypatch.setattr("codealmanac.cli.main.create_app", lambda: app) + + assert main(["config", "set", "telemetry.enabled", "false", "--json"]) == 0 + + assert app.config.load_user().telemetry.enabled is False + assert sender.events == [] + + +def test_uninstall_preserves_preexisting_opt_out_and_removes_identity_state( + isolated_home: Path, + monkeypatch, +) -> None: + sender = RecordingSender() + database_path = isolated_home / ".codealmanac/codealmanac.db" + app = create_app( + AppConfig(database_path=database_path), + scheduler=SkippedScheduler(), + telemetry_sender=sender, + package_uninstaller=SkippedPackageUninstaller(), + ) + monkeypatch.setattr("codealmanac.cli.main.create_app", lambda: app) + + assert main(["config", "set", "telemetry.enabled", "false", "--json"]) == 0 + assert main(["uninstall", "--yes", "--json"]) == 0 + + assert sender.events == [] + assert not database_path.exists() + + +def test_main_captures_crash_and_reraises( + tmp_path: Path, + monkeypatch, +) -> None: + sender = RecordingSender() + app = create_app( + AppConfig(database_path=tmp_path / "codealmanac.db"), + telemetry_sender=sender, + ) + monkeypatch.setattr("codealmanac.cli.main.create_app", lambda: app) + + def crash(args, app): + raise RuntimeError(f"boom at {tmp_path} token=private") + + monkeypatch.setattr("codealmanac.cli.execution.dispatch_app", crash) + + with pytest.raises(RuntimeError, match="boom"): + main(["health"]) + + assert [event.name for event in sender.events] == [ + "$exception", + "cli command completed", + ] + assert sender.events[-1].properties["outcome"] == "crashed" + assert str(tmp_path) not in sender.events[0].model_dump_json() + assert "private" not in sender.events[0].model_dump_json() + + +def test_exception_shaping_cannot_replace_original_crash( + tmp_path: Path, + monkeypatch, +) -> None: + class BrokenStringError(RuntimeError): + def __str__(self) -> str: + raise RuntimeError("exception stringification failed") + + sender = RecordingSender() + app = create_app( + AppConfig(database_path=tmp_path / "codealmanac.db"), + telemetry_sender=sender, + ) + monkeypatch.setattr("codealmanac.cli.main.create_app", lambda: app) + + def crash(args, app): + raise BrokenStringError + + monkeypatch.setattr("codealmanac.cli.execution.dispatch_app", crash) + + with pytest.raises(BrokenStringError): + main(["health"]) + + assert [event.name for event in sender.events] == [ + "$exception", + "cli command completed", + ] + assert sender.events[0].properties["$exception_message"] == "BrokenStringError" + + +def test_main_captures_interruption_and_reraises( + tmp_path: Path, + monkeypatch, +) -> None: + sender = RecordingSender() + app = create_app( + AppConfig(database_path=tmp_path / "codealmanac.db"), + telemetry_sender=sender, + ) + monkeypatch.setattr("codealmanac.cli.main.create_app", lambda: app) + + def interrupt(args, app): + raise KeyboardInterrupt + + monkeypatch.setattr("codealmanac.cli.execution.dispatch_app", interrupt) + + with pytest.raises(KeyboardInterrupt): + main(["health"]) + + assert [event.name for event in sender.events] == ["cli command completed"] + assert sender.events[0].properties["outcome"] == "interrupted" + assert sender.events[0].properties["exit_code"] == 130 + + +def test_jobs_attach_returned_130_is_captured_as_interrupted( + tmp_path: Path, + monkeypatch, +) -> None: + sender = RecordingSender() + app = create_app( + AppConfig(database_path=tmp_path / "codealmanac.db"), + telemetry_sender=sender, + ) + monkeypatch.setattr("codealmanac.cli.main.create_app", lambda: app) + + def interrupted_attach(_request): + def updates(): + raise KeyboardInterrupt + yield + + return updates() + + monkeypatch.setattr(app.runs, "stream_attach", interrupted_attach) + + assert main(["jobs", "attach", "garden-attach-test"]) == 130 + + event = sender.events[0] + assert event.properties["command"] == "jobs" + assert event.properties["action"] == "attach" + assert event.properties["outcome"] == "interrupted" + assert event.properties["exit_code"] == 130 + + +def test_main_redacts_user_arguments_echoed_by_an_unhandled_error( + tmp_path: Path, + monkeypatch, +) -> None: + sender = RecordingSender() + app = create_app( + AppConfig(database_path=tmp_path / "codealmanac.db"), + telemetry_sender=sender, + ) + monkeypatch.setattr("codealmanac.cli.main.create_app", lambda: app) + + def crash(args, app): + raise RuntimeError(f"unexpected query: {args.query}") + + monkeypatch.setattr("codealmanac.cli.execution.dispatch_app", crash) + + with pytest.raises(RuntimeError, match="unexpected query"): + main(["search", "private-customer-query"]) + + assert "private-customer-query" not in sender.events[0].model_dump_json() + + +@pytest.mark.parametrize( + ("argv", "expected_process_kind"), + ( + (("__run-executor", "private-run-id"), "executor"), + (("__run-worker", "--cwd", "/private/repository"), "worker"), + (("__garden-scheduler",), "scheduler"), + ), +) +def test_hidden_process_crash_has_process_kind_but_no_command_event( + tmp_path: Path, + monkeypatch, + argv: tuple[str, ...], + expected_process_kind: str, +) -> None: + sender = RecordingSender() + app = create_app( + AppConfig(database_path=tmp_path / "codealmanac.db"), + telemetry_sender=sender, + ) + monkeypatch.setattr("codealmanac.cli.main.create_app", lambda: app) + + def crash(args, app): + raise RuntimeError("unexpected executor crash") + + monkeypatch.setattr("codealmanac.cli.execution.dispatch_app", crash) + + with pytest.raises(RuntimeError, match="executor"): + main(list(argv)) + + assert [event.name for event in sender.events] == ["$exception"] + assert sender.events[0].properties["process_kind"] == expected_process_kind + assert "private-run-id" not in sender.events[0].model_dump_json() + assert "/private/repository" not in sender.events[0].model_dump_json() diff --git a/tests/test_config_service.py b/tests/test_config_service.py index 18c997a0..2fff85bc 100644 --- a/tests/test_config_service.py +++ b/tests/test_config_service.py @@ -55,9 +55,10 @@ def test_config_service_returns_defaults_without_file( assert config.auto_commit is True assert config.harness.default == HarnessKind.CODEX assert config.harness.model == "gpt-5.5" + assert config.telemetry.enabled is True assert config.automation.sync.enabled is True assert config.automation.sync.every == timedelta(hours=5) - assert config.automation.garden.every == timedelta(hours=4) + assert config.automation.garden.every == timedelta(hours=24) assert config.automation.update.every == timedelta(hours=24) @@ -74,6 +75,9 @@ def test_config_service_loads_complete_user_toml( default = "claude" model = "claude-opus-4-7" +[telemetry] +enabled = false + [automation.sync] enabled = true every = "30m" @@ -92,6 +96,7 @@ def test_config_service_loads_complete_user_toml( assert config.auto_commit is False assert config.harness.default == HarnessKind.CLAUDE + assert config.telemetry.enabled is False assert config.automation.sync.every == timedelta(minutes=30) assert config.automation.garden.enabled is False assert config.automation.update.every == timedelta(days=1) @@ -146,11 +151,21 @@ def test_config_set_writes_normal_values(isolated_home: Path) -> None: app.config.set(set_request(ConfigKey.AUTO_COMMIT, "false")) app.config.set(set_request(ConfigKey.HARNESS_DEFAULT, "claude")) app.config.set(set_request(ConfigKey.HARNESS_MODEL, "claude-opus-4-7")) + app.config.set(set_request(ConfigKey.TELEMETRY_ENABLED, "false")) config = app.config.load_user() assert config.auto_commit is False assert config.harness.default == HarnessKind.CLAUDE assert config.harness.model == "claude-opus-4-7" + assert config.telemetry.enabled is False + + +def test_config_list_includes_telemetry_enabled(isolated_home: Path) -> None: + entries = { + entry.key: entry.value for entry in config_app(isolated_home).config.list() + } + + assert entries[ConfigKey.TELEMETRY_ENABLED] == "true" def test_config_set_automation_interval_writes_and_reconciles( @@ -294,6 +309,11 @@ def test_config_set_rejects_invalid_values_without_writing( app.config.set(set_request(ConfigKey.AUTOMATION_SYNC_EVERY, "0s")) with pytest.raises(ValidationFailed, match="harness.default must be one of"): app.config.set(set_request(ConfigKey.HARNESS_DEFAULT, "gpt")) + with pytest.raises( + ValidationFailed, + match="telemetry.enabled must be true or false", + ): + app.config.set(set_request(ConfigKey.TELEMETRY_ENABLED, "maybe")) assert not path.exists() diff --git a/tests/test_garden_workflow.py b/tests/test_garden_workflow.py index 89c37881..901da7a3 100644 --- a/tests/test_garden_workflow.py +++ b/tests/test_garden_workflow.py @@ -15,7 +15,11 @@ HarnessTranscriptRef, ) from codealmanac.services.harnesses.requests import RunHarnessRequest -from codealmanac.services.runs.models import RunEventKind, RunStatus +from codealmanac.services.runs.models import ( + RunEventKind, + RunFailureCategory, + RunStatus, +) from codealmanac.services.runs.requests import ListRunsRequest, ReadRunLogRequest from codealmanac.services.search.requests import SearchPagesRequest from codealmanac.settings import AppConfig @@ -289,6 +293,7 @@ def test_garden_workflow_records_failed_harness_output_before_error( assert run.status == RunStatus.FAILED assert run.error == "harness codex failed with status failed: garden agent failed" + assert run.failure_category == RunFailureCategory.PROVIDER_EXECUTION assert run.harness_transcript is not None assert run.harness_transcript.session_id == "failed-garden-session" assert tuple(entry.kind for entry in log)[-3:] == ( diff --git a/tests/test_harnesses_service.py b/tests/test_harnesses_service.py index 2853ae43..00bd568c 100644 --- a/tests/test_harnesses_service.py +++ b/tests/test_harnesses_service.py @@ -3,16 +3,22 @@ import pytest from pydantic import ValidationError -from codealmanac.core.errors import ConflictError, ExecutionFailed, NotFoundError +from codealmanac.core.errors import ConflictError, NotFoundError from codealmanac.services.harnesses.models import ( HarnessAgentKind, + HarnessEvent, + HarnessEventKind, HarnessKind, HarnessReadiness, HarnessRunResult, HarnessRunStatus, ) from codealmanac.services.harnesses.requests import RunHarnessRequest -from codealmanac.services.harnesses.service import HarnessesService +from codealmanac.services.harnesses.service import ( + HarnessesService, + HarnessEventSinkFailed, + HarnessUnavailable, +) class FakeHarnessAdapter: @@ -42,7 +48,8 @@ def test_harnesses_service_runs_registered_adapter(tmp_path: Path): adapter = FakeHarnessAdapter(HarnessKind.CODEX) service = HarnessesService((adapter,)) - result = service.run( + service.ensure_ready(HarnessKind.CODEX) + result = service.run_ready( RunHarnessRequest( kind=HarnessKind.CODEX, model="gpt-5.5", @@ -77,20 +84,12 @@ def run(self, request: RunHarnessRequest, on_event=None) -> HarnessRunResult: raise AssertionError("an unavailable harness never runs") -def test_harnesses_service_refuses_to_run_unavailable_harness(tmp_path: Path): +def test_harnesses_service_ensure_ready_rejects_unavailable_harness(): adapter = UnavailableHarnessAdapter(HarnessKind.CODEX) service = HarnessesService((adapter, FakeHarnessAdapter(HarnessKind.CLAUDE))) - with pytest.raises(ExecutionFailed) as excinfo: - service.run( - RunHarnessRequest( - kind=HarnessKind.CODEX, - model="gpt-5.5", - agent=HarnessAgentKind.BUILD, - cwd=tmp_path, - prompt="Build the wiki.", - ) - ) + with pytest.raises(HarnessUnavailable) as excinfo: + service.ensure_ready(HarnessKind.CODEX) message = str(excinfo.value) assert "harness codex is not available: Error: spawn codex ENOENT" in message @@ -115,10 +114,36 @@ def test_harnesses_service_ensure_ready_raises_for_unregistered_kind(): service.ensure_ready(HarnessKind.CLAUDE) +def test_harnesses_service_distinguishes_event_sink_failure(tmp_path: Path): + class EventfulHarnessAdapter(FakeHarnessAdapter): + def run(self, request, on_event=None): + assert on_event is not None + on_event(HarnessEvent(kind=HarnessEventKind.TEXT, message="working")) + return super().run(request, on_event) + + adapter = EventfulHarnessAdapter(HarnessKind.CODEX) + service = HarnessesService((adapter,)) + sink_error = OSError("run-event store unavailable") + + with pytest.raises(HarnessEventSinkFailed) as excinfo: + service.run_ready( + RunHarnessRequest( + kind=HarnessKind.CODEX, + model="gpt-5.5", + agent=HarnessAgentKind.BUILD, + cwd=tmp_path, + prompt="Build the wiki.", + ), + on_event=lambda event: (_ for _ in ()).throw(sink_error), + ) + + assert excinfo.value.error is sink_error + + def test_unavailable_message_omits_switch_hint_without_alternatives(tmp_path: Path): service = HarnessesService((UnavailableHarnessAdapter(HarnessKind.CODEX),)) - with pytest.raises(ExecutionFailed) as excinfo: + with pytest.raises(HarnessUnavailable) as excinfo: service.ensure_ready(HarnessKind.CODEX) assert "switch harness" not in str(excinfo.value) @@ -149,19 +174,11 @@ def test_harnesses_service_readiness_answers_for_unregistered_kind(): assert unregistered.message == "no claude harness adapter is registered" -def test_harnesses_service_rejects_missing_or_duplicate_adapters(tmp_path: Path): +def test_harnesses_service_rejects_missing_or_duplicate_adapters(): service = HarnessesService() with pytest.raises(NotFoundError): - service.run( - RunHarnessRequest( - kind=HarnessKind.CODEX, - model="gpt-5.5", - agent=HarnessAgentKind.BUILD, - cwd=tmp_path, - prompt="Try a run without an adapter.", - ) - ) + service.ensure_ready(HarnessKind.CODEX) with pytest.raises(ConflictError): HarnessesService( diff --git a/tests/test_ingest_workflow.py b/tests/test_ingest_workflow.py index ba763f64..032c8dc0 100644 --- a/tests/test_ingest_workflow.py +++ b/tests/test_ingest_workflow.py @@ -7,7 +7,7 @@ from pydantic import ValidationError from codealmanac.app import create_app -from codealmanac.core.errors import ExecutionFailed, NotFoundError +from codealmanac.core.errors import ExecutionFailed, NotFoundError, ValidationFailed from codealmanac.integrations.sources.web import WebSourceRuntimeAdapter from codealmanac.services.harnesses.models import ( HarnessActorConfidence, @@ -28,8 +28,16 @@ HarnessUsage, ) from codealmanac.services.harnesses.requests import RunHarnessRequest -from codealmanac.services.runs.models import RunEventKind, RunStatus -from codealmanac.services.runs.requests import ListRunsRequest, ReadRunLogRequest +from codealmanac.services.runs.models import ( + RunEventKind, + RunFailureCategory, + RunStatus, +) +from codealmanac.services.runs.requests import ( + ListRunsRequest, + ReadRunLogRequest, + ShowRunRequest, +) from codealmanac.services.search.requests import SearchPagesRequest from codealmanac.services.sources.models import ( SourceKind, @@ -43,6 +51,7 @@ ) from codealmanac.settings import AppConfig from codealmanac.workflows.ingest.requests import IngestRequest, StartedIngestRequest +from codealmanac.workflows.operations.requests import BeginOperationRequest class WritingHarnessAdapter: @@ -292,6 +301,13 @@ def run_ingest(app, request: IngestRequest): ) +def begin_ingest_operation(app, request: IngestRequest): + queued = app.workflows.queue.queue_ingest(request) + operations = app.workflows.ingest.operations + context = operations.begin(BeginOperationRequest(run_id=queued.run_id)) + return operations, context, queued + + class RecordingPathSourceRuntimeAdapter: def __init__(self): self.requests: list[InspectSourceRuntimeRequest] = [] @@ -623,9 +639,148 @@ def test_ingest_workflow_fails_run_when_harness_is_missing( assert run.status == RunStatus.FAILED assert run.error == "harness not found: codex" + assert run.failure_category == RunFailureCategory.HARNESS_READINESS assert RunEventKind.ERROR in {entry.kind for entry in log} +def test_ingest_workflow_classifies_invalid_source_as_source_preparation( + tmp_path: Path, + isolated_home: Path, +) -> None: + repo = tmp_path / "repo" + repo.mkdir() + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db") + ) + initialize_repository(app, path=repo) + + with pytest.raises(ValidationFailed, match="invalid GitHub source address"): + run_ingest( + app, + IngestRequest( + cwd=repo, + inputs=("github:invalid",), + harness=HarnessKind.CODEX, + model="gpt-5.5", + ), + ) + + run = app.runs.list(ListRunsRequest(repository_name="repo"))[0] + assert run.status == RunStatus.FAILED + assert run.failure_category == RunFailureCategory.SOURCE_PREPARATION + + +def test_operation_failure_finishes_when_error_event_write_fails( + tmp_path: Path, + isolated_home: Path, + monkeypatch, +) -> None: + repo = tmp_path / "repo" + repo.mkdir() + (repo / "note.md").write_text("auth decision\n", encoding="utf-8") + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db") + ) + initialize_repository(app, path=repo) + operations, context, queued = begin_ingest_operation( + app, + IngestRequest( + cwd=repo, + inputs=("note.md",), + harness=HarnessKind.CODEX, + model="gpt-5.5", + ), + ) + monkeypatch.setattr( + operations, + "record", + lambda _request: (_ for _ in ()).throw(OSError("event write failed")), + ) + + operations.fail( + context, + ValidationFailed("invalid source"), + RunFailureCategory.SOURCE_PREPARATION, + ) + + stored = app.runs.show(ShowRunRequest(run_id=queued.run_id)) + assert stored.status == RunStatus.FAILED + assert stored.failure_category == RunFailureCategory.SOURCE_PREPARATION + assert stored.error == "invalid source" + + +def test_operation_failure_handles_broken_exception_stringification( + tmp_path: Path, + isolated_home: Path, +) -> None: + class BrokenStringError(RuntimeError): + def __str__(self) -> str: + raise RuntimeError("stringification failed") + + repo = tmp_path / "repo" + repo.mkdir() + (repo / "note.md").write_text("auth decision\n", encoding="utf-8") + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db") + ) + initialize_repository(app, path=repo) + operations, context, queued = begin_ingest_operation( + app, + IngestRequest( + cwd=repo, + inputs=("note.md",), + harness=HarnessKind.CODEX, + model="gpt-5.5", + ), + ) + + operations.fail( + context, + BrokenStringError(), + RunFailureCategory.SOURCE_PREPARATION, + ) + + stored = app.runs.show(ShowRunRequest(run_id=queued.run_id)) + assert stored.status == RunStatus.FAILED + assert stored.failure_category == RunFailureCategory.SOURCE_PREPARATION + assert stored.error == "BrokenStringError" + + +def test_ingest_workflow_classifies_index_refresh_failure_as_indexing( + tmp_path: Path, + isolated_home: Path, + monkeypatch, +) -> None: + repo = tmp_path / "repo" + repo.mkdir() + (repo / "note.md").write_text("auth decision\n", encoding="utf-8") + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db"), + harness_adapters=(WritingHarnessAdapter(),), + ) + initialize_repository(app, path=repo) + monkeypatch.setattr( + app.index, + "ensure_fresh", + lambda _repository_id: (_ for _ in ()).throw(OSError("index failed")), + ) + + with pytest.raises(OSError, match="index failed"): + run_ingest( + app, + IngestRequest( + cwd=repo, + inputs=("note.md",), + harness=HarnessKind.CODEX, + model="gpt-5.5", + ), + ) + + run = app.runs.list(ListRunsRequest(repository_name="repo"))[0] + assert run.status == RunStatus.FAILED + assert run.failure_category == RunFailureCategory.INDEXING + + def test_ingest_workflow_ignores_reported_changed_files( tmp_path: Path, isolated_home: Path, @@ -689,6 +844,7 @@ def test_ingest_workflow_fails_when_harness_returns_failed_status( assert run.status == RunStatus.FAILED assert run.error == "harness codex failed with status failed: agent failed" + assert run.failure_category == RunFailureCategory.PROVIDER_EXECUTION assert run.harness_transcript is not None assert run.harness_transcript.session_id == "failed-ingest-session" assert tuple(entry.kind for entry in log)[-3:] == ( diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py index 4dd593d0..c033f670 100644 --- a/tests/test_public_contract.py +++ b/tests/test_public_contract.py @@ -34,6 +34,7 @@ README_REQUIRED_FRAGMENTS = ( "Public command: `codealmanac`", + "Short alias: `ca`", "Repo wiki root: `almanac/` only", "Alternate repo wiki roots: none", "User state root: `~/.codealmanac/`", @@ -57,6 +58,10 @@ ) README_FORBIDDEN_FRAGMENTS = ( + "curl -fsSL", + "python -m pip install codealmanac", + "Give this prompt to your coding agent", + "hand this prompt to your coding agent", "npx codealmanac", # codealmanac itself is never installed from npm; harness CLIs (codex, # claude) are npm packages and their repair hints may name npm. @@ -130,12 +135,15 @@ ) -def test_public_entry_point_is_codealmanac_only(): +def test_public_entry_points_include_ca_alias(): pyproject = tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text()) scripts = pyproject["project"]["scripts"] - assert scripts == {"codealmanac": "codealmanac.cli.main:main"} + assert scripts == { + "ca": "codealmanac.cli.main:main", + "codealmanac": "codealmanac.cli.main:main", + } def test_python_package_metadata_declares_readme_and_license(): diff --git a/tests/test_read_model.py b/tests/test_read_model.py index 8f4359a5..4a0e07e9 100644 --- a/tests/test_read_model.py +++ b/tests/test_read_model.py @@ -69,6 +69,186 @@ def test_search_indexes_pages_topics_mentions_and_links( assert page.page_links_in == ("auth-flow",) +def test_natural_question_matches_relevant_section_without_requiring_every_word( + tmp_path: Path, + isolated_home: Path, +): + repo = tmp_path / "repo" + repo.mkdir() + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db") + ) + initialize_repository(app, path=repo) + write_page( + repo, + "run-lifecycle.md", + """# Run Lifecycle + +## Running Cancellation + +Cancellation terminates the executor process and records the run as cancelled. +""", + ) + + rows = app.search.search( + SearchPagesRequest( + cwd=repo, + query="How does cancellation terminate a running agent?", + ) + ) + + assert rows[0].slug == "run-lifecycle" + assert rows[0].matched_heading == "Run Lifecycle › Running Cancellation" + assert "terminates the executor" in (rows[0].excerpt or "") + + +def test_section_matches_collapse_to_one_page_with_best_evidence( + tmp_path: Path, + isolated_home: Path, +): + repo = tmp_path / "repo" + repo.mkdir() + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db") + ) + initialize_repository(app, path=repo) + write_page( + repo, + "workers.md", + """# Workers + +## Claims + +Workers claim queued jobs. + +## Recovery + +Workers recover expired claims and queued jobs. +""", + ) + + rows = app.search.search(SearchPagesRequest(cwd=repo, query="expired claims")) + + assert [row.slug for row in rows].count("workers") == 1 + assert rows[0].matched_heading == "Workers › Recovery" + + +def test_text_search_limit_counts_ranked_pages_not_matching_sections( + tmp_path: Path, + isolated_home: Path, +): + repo = tmp_path / "repo" + repo.mkdir() + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db") + ) + initialize_repository(app, path=repo) + write_page( + repo, + "workers.md", + """# Workers + +## Claims + +Workers claim queued jobs. + +## Recovery + +Workers recover expired claims and queued jobs. +""", + ) + write_page(repo, "claims.md", "# Claims\n\nExpired claims are reassigned.\n") + write_page(repo, "queue.md", "# Queue\n\nQueued jobs wait for workers.\n") + + rows = app.search.search( + SearchPagesRequest(cwd=repo, query="expired claims queued", limit=2) + ) + + assert len(rows) == 2 + assert len({row.slug for row in rows}) == 2 + workers = next(row for row in rows if row.slug == "workers") + assert workers.matched_heading == "Workers › Recovery" + + +def test_title_and_heading_matches_rank_above_body_only_matches( + tmp_path: Path, + isolated_home: Path, +): + repo = tmp_path / "repo" + repo.mkdir() + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db") + ) + initialize_repository(app, path=repo) + write_page(repo, "title.md", "# Nebula\n\nGeneral notes.\n") + write_page(repo, "heading.md", "# Notes\n\n## Nebula\n\nGeneral notes.\n") + write_page(repo, "body.md", "# Notes\n\nThe nebula behavior is documented.\n") + + rows = app.search.search(SearchPagesRequest(cwd=repo, query="nebula")) + + assert [row.slug for row in rows] == ["title", "heading", "body"] + + +def test_text_search_composes_with_topic_and_mention_filters( + tmp_path: Path, + isolated_home: Path, +): + repo = tmp_path / "repo" + repo.mkdir() + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db") + ) + initialize_repository(app, path=repo) + write_page( + repo, + "normalization.md", + """--- +topics: [paths] +sources: + - id: paths + type: file + path: src/codealmanac/services/wiki/paths.py +--- +# Normalization + +## Reference Paths + +Normalize path separators before indexing references. +""", + ) + write_page(repo, "other.md", "# Other\n\nNormalize unrelated values.\n") + + rows = app.search.search( + SearchPagesRequest( + cwd=repo, + query="where is path normalization handled", + topics=("paths",), + mentions="src/codealmanac/services/wiki/paths.py", + ) + ) + + assert [row.slug for row in rows] == ["normalization"] + + +def test_punctuation_only_query_keeps_structured_filters_useful( + tmp_path: Path, + isolated_home: Path, +): + repo = tmp_path / "repo" + repo.mkdir() + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db") + ) + initialize_repository(app, path=repo) + write_page(repo, "paths.md", "---\ntopics: [paths]\n---\n# Paths\n") + + rows = app.search.search( + SearchPagesRequest(cwd=repo, query="?!", topics=("paths",)) + ) + + assert [row.slug for row in rows] == ["paths"] + + def test_legacy_files_frontmatter_does_not_create_file_refs( tmp_path: Path, isolated_home: Path, diff --git a/tests/test_run_queue_workflow.py b/tests/test_run_queue_workflow.py index fc4d96e4..5598281e 100644 --- a/tests/test_run_queue_workflow.py +++ b/tests/test_run_queue_workflow.py @@ -172,6 +172,108 @@ def test_run_queue_drains_persisted_ingest_spec( ) +def test_run_queue_processes_work_queued_at_idle_handoff( + tmp_path: Path, + isolated_home: Path, + monkeypatch: pytest.MonkeyPatch, +): + repo = tmp_path / "repo" + repo.mkdir() + (repo / "note.md").write_text("late queue design note\n", encoding="utf-8") + harness = QueueWritingHarnessAdapter() + executors = InlineRunExecutorSpawner() + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db"), + harness_adapters=(harness,), + executor_spawner=executors, + process_controller=FakeRunProcessController(), + ) + bind_inline_executor(app, executors) + initialize_repository(app, repo) + initialize_git(repo) + commit_all(repo, "initial wiki") + release_if_idle = app.runs.release_worker_if_idle + queued_run_id: str | None = None + + def queue_before_handoff(request): + nonlocal queued_run_id + if queued_run_id is None: + queued = app.workflows.queue.queue_ingest( + IngestRequest( + cwd=repo, + inputs=("note.md",), + harness=HarnessKind.CODEX, + model="gpt-5.5", + auto_commit=False, + ) + ) + queued_run_id = queued.run_id + return release_if_idle(request) + + monkeypatch.setattr(app.runs, "release_worker_if_idle", queue_before_handoff) + + result = app.workflows.queue.drain(DrainRunQueueRequest()) + + assert queued_run_id is not None + assert [record.run_id for record in result.processed] == [queued_run_id] + assert app.runs.show(ShowRunRequest(run_id=queued_run_id)).status == RunStatus.DONE + assert len(harness.requests) == 1 + + +def test_run_queue_max_runs_leaves_later_work_for_next_worker( + tmp_path: Path, + isolated_home: Path, +): + repo = tmp_path / "repo" + repo.mkdir() + (repo / "first.md").write_text("first queue note\n", encoding="utf-8") + (repo / "second.md").write_text("second queue note\n", encoding="utf-8") + harness = QueueWritingHarnessAdapter() + executors = InlineRunExecutorSpawner() + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db"), + harness_adapters=(harness,), + executor_spawner=executors, + process_controller=FakeRunProcessController(), + ) + bind_inline_executor(app, executors) + initialize_repository(app, repo) + initialize_git(repo) + commit_all(repo, "initial wiki") + first = app.workflows.queue.queue_ingest( + IngestRequest( + cwd=repo, + inputs=("first.md",), + harness=HarnessKind.CODEX, + model="gpt-5.5", + auto_commit=False, + ) + ) + second = app.workflows.queue.queue_ingest( + IngestRequest( + cwd=repo, + inputs=("second.md",), + harness=HarnessKind.CODEX, + model="gpt-5.5", + auto_commit=False, + ) + ) + + first_drain = app.workflows.queue.drain(DrainRunQueueRequest(max_runs=1)) + + assert [record.run_id for record in first_drain.processed] == [first.run_id] + assert app.runs.show(ShowRunRequest(run_id=first.run_id)).status == RunStatus.DONE + assert ( + app.runs.show(ShowRunRequest(run_id=second.run_id)).status + == RunStatus.QUEUED + ) + + second_drain = app.workflows.queue.drain(DrainRunQueueRequest(max_runs=1)) + + assert [record.run_id for record in second_drain.processed] == [second.run_id] + assert app.runs.show(ShowRunRequest(run_id=second.run_id)).status == RunStatus.DONE + + def test_run_queue_drains_persisted_build_spec( tmp_path: Path, isolated_home: Path, diff --git a/tests/test_run_telemetry.py b/tests/test_run_telemetry.py new file mode 100644 index 00000000..c3477fea --- /dev/null +++ b/tests/test_run_telemetry.py @@ -0,0 +1,393 @@ +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from codealmanac.app import create_app +from codealmanac.services.config.models import ConfigKey +from codealmanac.services.config.requests import SetConfigValueRequest +from codealmanac.services.harnesses.models import HarnessKind +from codealmanac.services.repositories.requests import RegisterRepositoryRequest +from codealmanac.services.runs.models import ( + RunExecutionRef, + RunFailureCategory, + RunKind, + RunSpec, + RunStatus, +) +from codealmanac.services.runs.requests import ( + CancelRunRequest, + FinishRunCancellationRequest, + FinishRunRequest, + MarkRunRunningRequest, + QueueRunRequest, + ReadRunSpecRequest, +) +from codealmanac.services.telemetry.models import TelemetryEvent +from codealmanac.settings import AppConfig + + +@pytest.fixture(autouse=True) +def enable_test_telemetry(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ("CODEALMANAC_NO_TELEMETRY", "DO_NOT_TRACK", "CI"): + monkeypatch.delenv(name, raising=False) + + +class RecordingSender: + def __init__(self): + self.events: list[TelemetryEvent] = [] + + def send(self, event: TelemetryEvent) -> None: + self.events.append(event) + + +def queued_run(tmp_path: Path, *, model: str = "gpt-5.5"): + sender = RecordingSender() + app = create_app( + AppConfig(database_path=tmp_path / "codealmanac.db"), + telemetry_sender=sender, + ) + repo = tmp_path / "repo" + repo.mkdir() + repository = app.repositories.register(RegisterRepositoryRequest(root_path=repo)) + run = app.runs.queue( + QueueRunRequest( + repository_id=repository.repository_id, + spec=RunSpec( + kind=RunKind.GARDEN, + harness=HarnessKind.CODEX, + model=model, + ), + ) + ) + return app, sender, run + + +def set_telemetry(app, *, enabled: bool) -> None: + app.config.set( + SetConfigValueRequest( + key=ConfigKey.TELEMETRY_ENABLED, + value=str(enabled).lower(), + ) + ) + + +def test_terminal_run_is_captured_exactly_once_without_identifiers( + tmp_path: Path, +) -> None: + app, sender, run = queued_run(tmp_path) + app.runs.mark_running(MarkRunRunningRequest(run_id=run.run_id)) + + request = FinishRunRequest(run_id=run.run_id, status=RunStatus.DONE) + app.runs.finish(request) + app.runs.finish(request) + + assert len(sender.events) == 1 + event = sender.events[0] + assert event.name == "lifecycle run completed" + assert event.properties["run_kind"] == "garden" + assert event.properties["status"] == "done" + assert event.properties["harness"] == "codex" + assert event.properties["model"] == "gpt-5.5" + assert "failure_category" not in event.properties + dumped = event.model_dump_json() + assert run.run_id not in dumped + assert str(tmp_path) not in dumped + + +def test_failed_run_has_only_controlled_failure_category(tmp_path: Path) -> None: + app, sender, run = queued_run(tmp_path) + app.runs.mark_running(MarkRunRunningRequest(run_id=run.run_id)) + + app.runs.finish( + FinishRunRequest( + run_id=run.run_id, + status=RunStatus.FAILED, + error="private provider output", + failure_category=RunFailureCategory.PROVIDER_EXECUTION, + ) + ) + + assert sender.events[0].properties["failure_category"] == "provider_execution" + assert "private provider output" not in sender.events[0].model_dump_json() + + +def test_lifecycle_event_drops_uncontrolled_private_model_text( + tmp_path: Path, +) -> None: + private_model = "/Users/alice/private-model-token" + app, sender, run = queued_run(tmp_path, model=private_model) + + app.runs.finish( + FinishRunRequest(run_id=run.run_id, status=RunStatus.DONE) + ) + + assert sender.events == [] + + +def test_queued_cancellation_emits_terminal_event(tmp_path: Path) -> None: + app, sender, run = queued_run(tmp_path) + + app.runs.prepare_cancellation(CancelRunRequest(run_id=run.run_id)) + + assert sender.events[0].properties["status"] == "cancelled" + + +def test_opted_out_finish_is_not_replayed_after_reenable(tmp_path: Path) -> None: + app, sender, run = queued_run(tmp_path) + app.runs.mark_running(MarkRunRunningRequest(run_id=run.run_id)) + request = FinishRunRequest(run_id=run.run_id, status=RunStatus.DONE) + + set_telemetry(app, enabled=False) + app.runs.finish(request) + set_telemetry(app, enabled=True) + app.runs.finish(request) + + assert sender.events == [] + + +def test_finish_succeeds_when_telemetry_spec_read_fails( + tmp_path: Path, + monkeypatch, +) -> None: + app, _sender, run = queued_run(tmp_path) + app.runs.mark_running(MarkRunRunningRequest(run_id=run.run_id)) + monkeypatch.setattr( + app.runs.store, + "read_spec", + lambda _run_id: (_ for _ in ()).throw(OSError("read spec failed")), + ) + + finished = app.runs.finish( + FinishRunRequest(run_id=run.run_id, status=RunStatus.DONE) + ) + + assert finished.status == RunStatus.DONE + + +def test_finish_succeeds_when_lifecycle_capture_raises( + tmp_path: Path, + monkeypatch, +) -> None: + app, _sender, run = queued_run(tmp_path) + app.runs.mark_running(MarkRunRunningRequest(run_id=run.run_id)) + monkeypatch.setattr( + app.telemetry, + "capture_lifecycle", + lambda *_args: (_ for _ in ()).throw(RuntimeError("capture failed")), + ) + + finished = app.runs.finish( + FinishRunRequest(run_id=run.run_id, status=RunStatus.DONE) + ) + + assert finished.status == RunStatus.DONE + + +def test_lifecycle_shaping_failures_never_escape( + tmp_path: Path, + monkeypatch, +) -> None: + app, sender, run = queued_run(tmp_path) + record = run.model_copy( + update={ + "status": RunStatus.DONE, + "finished_at": run.created_at, + } + ) + spec = app.runs.read_spec(ReadRunSpecRequest(run_id=run.run_id)) + assert spec is not None + monkeypatch.setattr( + "codealmanac.services.telemetry.service.duration_bucket", + lambda _seconds: (_ for _ in ()).throw(RuntimeError("shaping failed")), + ) + + app.telemetry.capture_lifecycle(record, spec) + + assert sender.events == [] + + +def test_opted_out_queued_cancellation_is_not_replayed_after_reenable( + tmp_path: Path, +) -> None: + app, sender, run = queued_run(tmp_path) + request = CancelRunRequest(run_id=run.run_id) + + set_telemetry(app, enabled=False) + app.runs.prepare_cancellation(request) + set_telemetry(app, enabled=True) + app.runs.prepare_cancellation(request) + + assert sender.events == [] + + +def test_queued_cancellation_succeeds_when_telemetry_spec_read_fails( + tmp_path: Path, + monkeypatch, +) -> None: + app, _sender, run = queued_run(tmp_path) + monkeypatch.setattr( + app.runs.store, + "read_spec", + lambda _run_id: (_ for _ in ()).throw(OSError("read spec failed")), + ) + + result = app.runs.prepare_cancellation(CancelRunRequest(run_id=run.run_id)) + + assert result.record.status == RunStatus.CANCELLED + + +def test_opted_out_running_cancellation_is_not_replayed_after_reenable( + tmp_path: Path, +) -> None: + app, sender, run = queued_run(tmp_path) + execution = RunExecutionRef( + execution_id="executor-1", + pid=4242, + process_started_at=datetime(2026, 1, 1, tzinfo=UTC), + ) + app.runs.mark_running( + MarkRunRunningRequest(run_id=run.run_id, execution=execution) + ) + app.runs.prepare_cancellation(CancelRunRequest(run_id=run.run_id)) + request = FinishRunCancellationRequest( + run_id=run.run_id, + execution_id=execution.execution_id, + ) + + set_telemetry(app, enabled=False) + app.runs.finish_cancellation(request) + set_telemetry(app, enabled=True) + app.runs.finish_cancellation(request) + + assert sender.events == [] + + +def test_running_cancellation_succeeds_when_telemetry_spec_read_fails( + tmp_path: Path, + monkeypatch, +) -> None: + app, _sender, run = queued_run(tmp_path) + execution = RunExecutionRef( + execution_id="executor-1", + pid=4242, + process_started_at=datetime(2026, 1, 1, tzinfo=UTC), + ) + app.runs.mark_running( + MarkRunRunningRequest(run_id=run.run_id, execution=execution) + ) + app.runs.prepare_cancellation(CancelRunRequest(run_id=run.run_id)) + monkeypatch.setattr( + app.runs.store, + "read_spec", + lambda _run_id: (_ for _ in ()).throw(OSError("read spec failed")), + ) + + result = app.runs.finish_cancellation( + FinishRunCancellationRequest( + run_id=run.run_id, + execution_id=execution.execution_id, + ) + ) + + assert result.record.status == RunStatus.CANCELLED + + +def test_worker_spawn_crash_emits_exception_and_failed_lifecycle( + tmp_path: Path, +) -> None: + class BrokenExecutorSpawner: + def spawn(self, request): + raise OSError(f"cannot spawn in {tmp_path} token=private") + + sender = RecordingSender() + app = create_app( + AppConfig(database_path=tmp_path / "codealmanac.db"), + telemetry_sender=sender, + executor_spawner=BrokenExecutorSpawner(), + ) + repo = tmp_path / "repo" + repo.mkdir() + repository = app.repositories.register(RegisterRepositoryRequest(root_path=repo)) + app.runs.queue( + QueueRunRequest( + repository_id=repository.repository_id, + spec=RunSpec( + kind=RunKind.GARDEN, + harness=HarnessKind.CODEX, + model="gpt-5.5", + ), + ) + ) + queued = app.runs.next_queued() + assert queued is not None + + record = app.workflows.queue.worker.run_queued(queued) + + assert record.status == RunStatus.FAILED + assert [event.name for event in sender.events] == [ + "$exception", + "lifecycle run completed", + ] + assert sender.events[0].properties["process_kind"] == "worker" + assert sender.events[1].properties["failure_category"] == "internal_error" + dumped = "".join(event.model_dump_json() for event in sender.events) + assert str(tmp_path) not in dumped + assert "private" not in dumped + + +def test_worker_wait_crash_is_captured_even_after_executor_finished( + tmp_path: Path, +) -> None: + class FinishThenBreakSpawner: + app = None + + def spawn(self, request): + app = self.app + + class Process: + def wait(self): + app.runs.mark_running( + MarkRunRunningRequest(run_id=request.run_id) + ) + app.runs.finish( + FinishRunRequest( + run_id=request.run_id, + status=RunStatus.DONE, + ) + ) + raise OSError("worker wait machinery failed") + + return Process() + + sender = RecordingSender() + spawner = FinishThenBreakSpawner() + app = create_app( + AppConfig(database_path=tmp_path / "codealmanac.db"), + telemetry_sender=sender, + executor_spawner=spawner, + ) + spawner.app = app + repo = tmp_path / "repo" + repo.mkdir() + repository = app.repositories.register(RegisterRepositoryRequest(root_path=repo)) + app.runs.queue( + QueueRunRequest( + repository_id=repository.repository_id, + spec=RunSpec( + kind=RunKind.GARDEN, + harness=HarnessKind.CODEX, + model="gpt-5.5", + ), + ) + ) + queued = app.runs.next_queued() + assert queued is not None + + record = app.workflows.queue.worker.run_queued(queued) + + assert record.status == RunStatus.DONE + assert [event.name for event in sender.events] == [ + "lifecycle run completed", + "$exception", + ] diff --git a/tests/test_runs_service.py b/tests/test_runs_service.py index 466f9e59..53529e8d 100644 --- a/tests/test_runs_service.py +++ b/tests/test_runs_service.py @@ -17,11 +17,13 @@ ) from codealmanac.services.runs.models import ( RunEventKind, + RunFailureCategory, RunKind, RunLogEvent, RunRecord, RunSpec, RunStatus, + RunWorkerIdleHandoffOutcome, ) from codealmanac.services.runs.requests import ( AcquireRunWorkerLockRequest, @@ -36,6 +38,7 @@ ReadRunSpecRequest, RecordRunEventRequest, RecordRunHarnessTranscriptRequest, + ReleaseRunWorkerIfIdleRequest, ShowRunRequest, StartRunRequest, StreamRunAttachRequest, @@ -179,6 +182,7 @@ def test_runs_service_refuses_running_transition_after_terminal_status( run_id=record.run_id, status=RunStatus.FAILED, error="failed before running", + failure_category=RunFailureCategory.INTERNAL_ERROR, ) ) @@ -186,6 +190,65 @@ def test_runs_service_refuses_running_transition_after_terminal_status( app.runs.mark_running(MarkRunRunningRequest(run_id=record.run_id)) +def test_failed_run_persists_failure_category( + tmp_path: Path, + isolated_home: Path, +) -> None: + repo = tmp_path / "repo" + repo.mkdir() + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db") + ) + repository = initialize_repository(app, path=repo) + record = app.runs.start( + StartRunRequest(repository_id=repository.repository_id, kind=RunKind.INGEST) + ) + + app.runs.finish( + FinishRunRequest( + run_id=record.run_id, + status=RunStatus.FAILED, + error="provider failed", + failure_category=RunFailureCategory.PROVIDER_EXECUTION, + ) + ) + + stored = app.runs.show(ShowRunRequest(run_id=record.run_id)) + assert stored.failure_category == RunFailureCategory.PROVIDER_EXECUTION + + +def test_finish_is_noop_after_first_terminal_transition( + tmp_path: Path, + isolated_home: Path, +) -> None: + repo = tmp_path / "repo" + repo.mkdir() + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db") + ) + repository = initialize_repository(app, path=repo) + record = app.runs.start( + StartRunRequest(repository_id=repository.repository_id, kind=RunKind.GARDEN) + ) + + first = app.runs.finish( + FinishRunRequest(run_id=record.run_id, status=RunStatus.DONE) + ) + second = app.runs.finish( + FinishRunRequest( + run_id=record.run_id, + status=RunStatus.FAILED, + error="late failure must not win", + failure_category=RunFailureCategory.INTERNAL_ERROR, + ) + ) + + log = app.runs.log(ReadRunLogRequest(run_id=record.run_id)) + assert second == first + assert tuple(event.message for event in log).count("done") == 1 + assert all(event.message != "failed" for event in log) + + def test_runs_service_cancels_queued_run_and_attaches_log( tmp_path: Path, isolated_home: Path, @@ -496,6 +559,76 @@ def test_runs_service_worker_lock_is_exclusive_and_recovers_stale_owner( after_release.release() +def test_worker_idle_handoff_releases_lock_when_queue_is_empty( + isolated_home: Path, +) -> None: + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db") + ) + now = datetime(2026, 7, 11, 12, 0, tzinfo=UTC) + lease = app.runs.acquire_worker_lock( + AcquireRunWorkerLockRequest(owner="first-worker", pid=os.getpid(), now=now) + ) + assert lease is not None + + outcome = app.runs.release_worker_if_idle( + ReleaseRunWorkerIfIdleRequest(owner=lease.owner) + ) + replacement = app.runs.acquire_worker_lock( + AcquireRunWorkerLockRequest( + owner="replacement-worker", + pid=os.getpid(), + now=now, + ) + ) + + assert outcome == RunWorkerIdleHandoffOutcome.RELEASED + assert replacement is not None + replacement.release() + + +def test_worker_idle_handoff_retains_lock_when_work_is_queued( + tmp_path: Path, + isolated_home: Path, +) -> None: + repo = tmp_path / "repo" + repo.mkdir() + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db") + ) + repository = initialize_repository(app, repo) + app.runs.queue( + QueueRunRequest( + repository_id=repository.repository_id, + spec=RunSpec( + kind=RunKind.GARDEN, + harness=HarnessKind.CODEX, + model="gpt-5.5", + ), + ) + ) + now = datetime(2026, 7, 11, 12, 0, tzinfo=UTC) + lease = app.runs.acquire_worker_lock( + AcquireRunWorkerLockRequest(owner="first-worker", pid=os.getpid(), now=now) + ) + assert lease is not None + + outcome = app.runs.release_worker_if_idle( + ReleaseRunWorkerIfIdleRequest(owner=lease.owner) + ) + competing = app.runs.acquire_worker_lock( + AcquireRunWorkerLockRequest( + owner="competing-worker", + pid=os.getpid(), + now=now, + ) + ) + + assert outcome == RunWorkerIdleHandoffOutcome.WORK_AVAILABLE + assert competing is None + lease.release() + + def test_finish_run_request_requires_terminal_status() -> None: with pytest.raises(ValidationError): FinishRunRequest( @@ -504,6 +637,21 @@ def test_finish_run_request_requires_terminal_status() -> None: ) +def test_finish_run_request_requires_category_only_for_failures() -> None: + with pytest.raises(ValidationError, match="failed runs require failure_category"): + FinishRunRequest(run_id="run-1", status=RunStatus.FAILED) + + with pytest.raises( + ValidationError, + match="failure_category is only valid for failed runs", + ): + FinishRunRequest( + run_id="run-1", + status=RunStatus.DONE, + failure_category=RunFailureCategory.INTERNAL_ERROR, + ) + + def test_run_id_requests_reject_path_shaped_identifiers() -> None: request_classes = ( ShowRunRequest, diff --git a/tests/test_search_query.py b/tests/test_search_query.py new file mode 100644 index 00000000..01364a1c --- /dev/null +++ b/tests/test_search_query.py @@ -0,0 +1,20 @@ +from codealmanac.services.index.query import analyze_search_query + + +def test_query_terms_are_escaped_prefixed_and_or_joined(): + query = analyze_search_query('How does cancellation "terminate" agent?') + + assert query == ( + '"how"* OR "does"* OR "cancellation"* OR "terminate"* OR "agent"*' + ) + + +def test_query_analysis_preserves_unicode_identifiers_and_deduplicates(): + query = analyze_search_query("ÜberCache übercache HTTP_404") + + assert query == '"übercache"* OR "http_404"*' + + +def test_punctuation_only_query_has_no_fts_expression(): + assert analyze_search_query(" -- ?! ") is None + diff --git a/tests/test_server.py b/tests/test_server.py index edc272bb..8a50708b 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -67,6 +67,8 @@ def test_server_serves_static_assets_and_viewer_api( assert page.json()["sources"][0]["target"] == "src/auth/session.py" assert '<a href="#/page/session-store">Session Store</a>' in page.json()["html"] assert [row["slug"] for row in search.json()["pages"]] == ["auth-flow"] + assert search.json()["pages"][0]["matched_heading"] == "Auth Flow" + assert "Login checks" in search.json()["pages"][0]["excerpt"] assert file.json()["kind"] == "file" assert [row["slug"] for row in file.json()["pages"]] == ["auth-flow"] assert [row["slug"] for row in topic.json()["pages"]] == [ diff --git a/tests/test_setup_service.py b/tests/test_setup_service.py index d930d901..e5228835 100644 --- a/tests/test_setup_service.py +++ b/tests/test_setup_service.py @@ -58,6 +58,9 @@ def test_setup_installs_codex_block_idempotently(home: Path): assert "codealmanac search --mentions src/checkout/" in body assert "## Reading pages" in body assert "codealmanac show PAGE --backlinks" in body + assert "## Citing Almanac in final answers" in body + assert "immediately after the claim it supports" in body + assert "almanac/architecture/setup/instruction-installation.md" in body assert "## Maintenance boundary" in body assert "codealmanac topics create" not in body assert first.plan.default_harness.value == "codex" @@ -102,6 +105,9 @@ def test_setup_installs_claude_guide_and_import_idempotently(home: Path): guide = guide_path.read_text(encoding="utf-8") assert "## Finding knowledge" in guide assert "## Reading pages" in guide + assert "## Citing Almanac in final answers" in guide + assert "immediately after the claim it supports" in guide + assert "almanac/architecture/setup/instruction-installation.md" in guide assert "## Maintenance boundary" in guide assert claude_md.count(CLAUDE_IMPORT_LINE) == 1 @@ -210,6 +216,14 @@ def test_setup_can_skip_sync_automation(home: Path): ) +def test_setup_persists_telemetry_choice(home: Path): + service = setup_service(home) + result = service.run(RunSetupRequest(telemetry_enabled=False)) + + assert result.config_update is not None + assert service._config.load_user().telemetry.enabled is False + + def test_uninstall_removes_automation_by_default(home: Path): automation = FakeSetupAutomationManager(home) diff --git a/tests/test_telemetry_sender.py b/tests/test_telemetry_sender.py new file mode 100644 index 00000000..e699b2e9 --- /dev/null +++ b/tests/test_telemetry_sender.py @@ -0,0 +1,221 @@ +import json +import subprocess +import sys + +from codealmanac.integrations.telemetry.sender import ( + DEFAULT_PROJECT_API_KEY, + POSTHOG_US_HOST, + SubprocessTelemetrySender, + deliver, + keep_validated_event_properties, +) +from codealmanac.services.telemetry.models import TelemetryEvent, TelemetryIdentity + + +def example_event() -> TelemetryEvent: + return TelemetryEvent( + event_id="233d1427-6ca3-4c44-b62f-1362acd768fe", + name="cli command completed", + identity=TelemetryIdentity( + installation_id="46918482-e884-4050-bcff-e94ef503cd0d", + distinct_id="46918482-e884-4050-bcff-e94ef503cd0d", + kind="anonymous", + ), + properties={ + "command": "health", + "action": "health", + "outcome": "success", + "exit_code": 0, + "duration_bucket": "<250ms", + "interactive": False, + "cli_version": "1.2.3", + "identity_kind": "anonymous", + "os_family": "darwin", + "os_major": "25", + "architecture": "arm64", + "python_version": "3.13", + "$geoip_disable": True, + }, + ) + + +def test_distribution_contains_only_a_public_project_key() -> None: + assert DEFAULT_PROJECT_API_KEY.startswith("phc_") + + +def test_importing_parent_sender_does_not_import_posthog_sdk() -> None: + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import sys; " + "import codealmanac.integrations.telemetry.sender; " + "print('posthog' in sys.modules)" + ), + ], + check=True, + capture_output=True, + text=True, + ) + + assert result.stdout.strip() == "False" + + +def test_subprocess_sender_detaches_and_writes_bounded_json(monkeypatch) -> None: + spawned: list[tuple[list[str], dict]] = [] + + class RecordingInput: + def __init__(self): + self.body = b"" + self.closed = False + + def write(self, body: bytes) -> None: + self.body += body + + def close(self) -> None: + self.closed = True + + class FakeProcess: + def __init__(self): + self.stdin = RecordingInput() + + process = FakeProcess() + + def fake_popen(command, **kwargs): + spawned.append((command, kwargs)) + return process + + monkeypatch.setattr( + "codealmanac.integrations.telemetry.sender.subprocess.Popen", + fake_popen, + ) + + SubprocessTelemetrySender(project_api_key="phc_public").send(example_event()) + + command, options = spawned[0] + assert command[-2:] == ["-m", "codealmanac.integrations.telemetry.sender"] + assert options["start_new_session"] is True + assert options["close_fds"] is True + assert options["env"]["CODEALMANAC_POSTHOG_PROJECT_KEY"] == "phc_public" + payload = json.loads(process.stdin.body) + assert payload["name"] == "cli command completed" + assert "phc_public" not in json.dumps(payload) + assert process.stdin.closed is True + + +def test_deliver_uses_uuid_geoip_disable_and_person_profile() -> None: + calls: list[tuple[tuple, dict]] = [] + + class FakeClient: + def capture(self, *args, **kwargs): + calls.append((args, kwargs)) + + def shutdown(self): + calls.append((("shutdown",), {})) + + clients: list[dict] = [] + + def client_factory(key: str, **kwargs): + clients.append({"key": key, **kwargs}) + return FakeClient() + + deliver(example_event(), "phc_public", client_factory=client_factory) + + assert len(clients) == 1 + client_options = clients[0] + before_send = client_options.pop("before_send") + assert before_send.func is keep_validated_event_properties + assert before_send.keywords == { + "allowed_properties": frozenset(example_event().properties) + } + assert client_options == { + "key": "phc_public", + "host": POSTHOG_US_HOST, + "sync_mode": True, + "timeout": 5, + "disable_geoip": True, + "enable_exception_autocapture": False, + "capture_exception_code_variables": False, + } + args, kwargs = calls[0] + assert args == ("cli command completed",) + assert kwargs["distinct_id"] == str(example_event().identity.installation_id) + assert kwargs["uuid"] == example_event().event_id + assert kwargs["disable_geoip"] is True + assert "$process_person_profile" not in kwargs["properties"] + assert calls[-1] == (("shutdown",), {}) + + +def test_sdk_context_is_removed_before_upload() -> None: + message = { + "properties": { + "$lib": "posthog-python", + "$lib_version": "7.24.0", + "$is_server": True, + "$os": "Mac OS X", + "$os_version": "private-full-version", + "$python_runtime": "CPython", + "$python_version": "3.13.3", + "$future_private_context": "must not leave the sender", + "os_family": "darwin", + "os_major": "25", + "python_version": "3.13", + } + } + + assert keep_validated_event_properties( + message, + allowed_properties=frozenset(("os_family", "os_major", "python_version")), + ) == { + "properties": { + "os_family": "darwin", + "os_major": "25", + "python_version": "3.13", + } + } + + +def test_sdk_property_filter_drops_event_if_rebuilding_fails() -> None: + class BrokenProperties(dict): + def __getitem__(self, key): + raise RuntimeError("unexpected SDK property container") + + message = {"properties": BrokenProperties(command="health")} + + assert keep_validated_event_properties( + message, + allowed_properties=frozenset(("command",)), + ) is None + + +def test_real_posthog_sdk_upload_contains_only_validated_properties( + monkeypatch, +) -> None: + uploaded: list[dict] = [] + + def record_batch(*args, batch, **kwargs) -> None: + uploaded.extend(batch) + + monkeypatch.setattr("posthog.client.batch_post", record_batch) + + event = example_event() + deliver(event, "phc_public") + + assert len(uploaded) == 1 + assert uploaded[0]["properties"] == event.properties + + +def test_sender_drops_oversized_payload_without_spawning(monkeypatch) -> None: + def fail_popen(*args, **kwargs): + raise AssertionError("should not spawn") + + monkeypatch.setattr( + "codealmanac.integrations.telemetry.sender.subprocess.Popen", + fail_popen, + ) + event = example_event().model_copy( + update={"properties": {"message": "x" * 70_000}} + ) + + SubprocessTelemetrySender(project_api_key="phc_public").send(event) diff --git a/tests/test_telemetry_service.py b/tests/test_telemetry_service.py new file mode 100644 index 00000000..360198a6 --- /dev/null +++ b/tests/test_telemetry_service.py @@ -0,0 +1,295 @@ +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from codealmanac.services.config.models import TelemetryConfig, UserConfig +from codealmanac.services.telemetry.models import ( + CliCommandCompletedProperties, + LifecycleRunCompletedProperties, + TelemetryEvent, +) +from codealmanac.services.telemetry.service import TelemetryService +from codealmanac.services.telemetry.store import TelemetryIdentityStore + + +@pytest.fixture(autouse=True) +def enable_test_telemetry(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ("CODEALMANAC_NO_TELEMETRY", "DO_NOT_TRACK", "CI"): + monkeypatch.delenv(name, raising=False) + + +class StubConfig: + def __init__(self, enabled: bool = True): + self.enabled = enabled + + def load_user(self) -> UserConfig: + return UserConfig(telemetry=TelemetryConfig(enabled=self.enabled)) + + +class RecordingSender: + def __init__(self): + self.events: list[TelemetryEvent] = [] + + def send(self, event: TelemetryEvent) -> None: + self.events.append(event) + + +def command_properties(command: str = "health") -> CliCommandCompletedProperties: + return CliCommandCompletedProperties( + command=command, + action=command, + outcome="success", + exit_code=0, + duration_bucket="<250ms", + interactive=False, + ) + + +def make_service( + database_path: Path, + *, + enabled: bool = True, +) -> tuple[TelemetryService, RecordingSender]: + sender = RecordingSender() + service = TelemetryService( + config=StubConfig(enabled), + identities=TelemetryIdentityStore(database_path), + sender=sender, + version="1.2.3", + ) + return service, sender + + +def test_lifecycle_properties_reject_model_for_another_harness() -> None: + with pytest.raises(ValidationError, match="model is not controlled for harness"): + LifecycleRunCompletedProperties( + run_kind="garden", + status="done", + harness="codex", + model="claude-sonnet-5", + duration_bucket="1-10s", + ) + + +def test_identity_is_a_stable_uuid_stored_in_sqlite(tmp_path: Path) -> None: + database_path = tmp_path / "state" / "codealmanac.db" + service, _ = make_service(database_path) + + first = service.identity() + second = make_service(database_path)[0].identity() + + assert first.installation_id.version == 4 + assert second == first + assert first.distinct_id == str(first.installation_id) + assert first.kind == "anonymous" + + +def test_disabled_policy_does_not_create_identity_or_send(tmp_path: Path) -> None: + database_path = tmp_path / "codealmanac.db" + service, sender = make_service(database_path, enabled=False) + + service.capture_command(command_properties()) + + assert sender.events == [] + assert not database_path.exists() + + +def test_capture_adds_only_controlled_common_properties(tmp_path: Path) -> None: + service, sender = make_service(tmp_path / "codealmanac.db") + + service.capture_command(command_properties("search")) + + assert len(sender.events) == 1 + event = sender.events[0] + assert event.name == "cli command completed" + assert event.identity.distinct_id == str(event.identity.installation_id) + assert event.properties == { + "command": "search", + "action": "search", + "outcome": "success", + "exit_code": 0, + "duration_bucket": "<250ms", + "interactive": False, + "cli_version": "1.2.3", + "identity_kind": "anonymous", + "os_family": event.properties["os_family"], + "os_major": event.properties["os_major"], + "architecture": event.properties["architecture"], + "python_version": event.properties["python_version"], + "$geoip_disable": True, + } + assert "$process_person_profile" not in event.properties + + +def test_capture_reloads_policy_for_each_event(tmp_path: Path) -> None: + config = StubConfig(enabled=True) + sender = RecordingSender() + service = TelemetryService( + config=config, + identities=TelemetryIdentityStore(tmp_path / "codealmanac.db"), + sender=sender, + version="1.2.3", + ) + + service.capture_command(command_properties()) + config.enabled = False + service.capture_command(command_properties("validate")) + + assert [event.properties["command"] for event in sender.events] == ["health"] + + +def test_sender_failures_never_escape(tmp_path: Path) -> None: + class BrokenSender: + def send(self, event: TelemetryEvent) -> None: + raise OSError("offline") + + service = TelemetryService( + config=StubConfig(), + identities=TelemetryIdentityStore(tmp_path / "codealmanac.db"), + sender=BrokenSender(), + version="1.2.3", + ) + + service.capture_command(command_properties()) + + +def test_capture_once_sends_only_the_first_attempt(tmp_path: Path) -> None: + database_path = tmp_path / "codealmanac.db" + service, sender = make_service(database_path) + + properties = { + "run_kind": "garden", + "status": "done", + "harness": "codex", + "model": "gpt-5.5", + "duration_bucket": "1-10s", + } + service.capture_once( + "run:abc:terminal", + "lifecycle run completed", + properties, + ) + second_service, second_sender = make_service(database_path) + second_service.capture_once( + "run:abc:terminal", + "lifecycle run completed", + properties, + ) + + assert [event.name for event in sender.events] == ["lifecycle run completed"] + assert second_sender.events == [] + + +@pytest.mark.parametrize( + ("name", "value"), + ( + ("CODEALMANAC_NO_TELEMETRY", "true"), + ("DO_NOT_TRACK", "1"), + ("CI", "yes"), + ), +) +def test_environment_policy_disables_capture( + tmp_path: Path, + monkeypatch, + name: str, + value: str, +) -> None: + service, sender = make_service(tmp_path / "codealmanac.db") + monkeypatch.setenv(name, value) + + service.capture_command(command_properties()) + + assert sender.events == [] + + +def test_event_allowlist_drops_unknown_properties(tmp_path: Path) -> None: + service, sender = make_service(tmp_path / "codealmanac.db") + + service.capture( + "cli command completed", + { + **command_properties().model_dump(), + "query": "must never leave the machine", + }, + ) + + assert sender.events == [] + + +def test_exception_capture_excludes_paths_secrets_and_source_code( + tmp_path: Path, +) -> None: + service, sender = make_service(tmp_path / "codealmanac.db") + secret_path = tmp_path / "private" / "input.py" + try: + raise RuntimeError( + f"failed at {secret_path} with token=secret-value and " + "https://user:password@example.com/api" + ) + except RuntimeError as error: + service.capture_exception( + error, + command="garden", + process_kind="executor", + ) + + event = sender.events[0] + assert event.name == "$exception" + dumped = event.model_dump_json() + assert str(tmp_path) not in dumped + assert "secret-value" not in dumped + assert "password" not in dumped + assert "raise RuntimeError" not in dumped + exception = event.properties["$exception_list"][0] + assert exception["mechanism"] == {"type": "generic", "handled": False} + assert exception["stacktrace"]["type"] == "raw" + assert event.properties["command"] == "garden" + assert event.properties["process_kind"] == "executor" + assert event.properties["$exception_fingerprint"] + + +def test_exception_capture_excludes_generic_paths_and_credentials( + tmp_path: Path, +) -> None: + service, sender = make_service(tmp_path / "codealmanac.db") + try: + raise RuntimeError( + "failed /Volumes/customer/private.py and C:\\Users\\Ada\\secret.py " + "password=hunter2 authorization=Bearer-private sk-live-secret" + ) + except RuntimeError as error: + service.capture_exception( + error, + command="garden", + process_kind="executor", + ) + + dumped = sender.events[0].model_dump_json() + assert "/Volumes/customer/private.py" not in dumped + assert "C:\\\\Users\\\\Ada" not in dumped + assert "hunter2" not in dumped + assert "Bearer-private" not in dumped + assert "sk-live-secret" not in dumped + + +def test_exception_capture_excludes_all_free_form_error_text(tmp_path: Path) -> None: + service, sender = make_service(tmp_path / "codealmanac.db") + private_text = ( + "customer-repo private-note.md prompt=delete-everything " + "provider output for Ada" + ) + try: + raise RuntimeError(private_text) + except RuntimeError as error: + service.capture_exception( + error, + command="garden", + process_kind="executor", + ) + + event = sender.events[0] + exception = event.properties["$exception_list"][0] + assert event.properties["$exception_message"] == "RuntimeError" + assert exception["value"] == "RuntimeError" + assert private_text not in event.model_dump_json() diff --git a/tests/test_update_service.py b/tests/test_update_service.py index aee07a79..10e4b134 100644 --- a/tests/test_update_service.py +++ b/tests/test_update_service.py @@ -1,11 +1,14 @@ from datetime import UTC, datetime from pathlib import Path +from filelock import FileLock + from codealmanac.services.harnesses.models import HarnessKind from codealmanac.services.repositories.models import Repository from codealmanac.services.repositories.store import RepositoryStore from codealmanac.services.runs.models import RunKind, RunSpec, RunStatus from codealmanac.services.runs.store import RunStore +from codealmanac.services.updates.lock import UpdateLockStore from codealmanac.services.updates.models import ( PackageCommandResult, PackageInstallMetadata, @@ -72,7 +75,7 @@ def test_update_service_plans_pip_upgrade_with_current_python(): assert plan.status == UpdateStatus.READY assert plan.method == UpdateInstallMethod.PIP assert plan.command == ( - "/venv/bin/python", + str(Path("/venv/bin/python")), "-m", "pip", "install", @@ -181,35 +184,40 @@ def test_scheduled_update_skips_when_lifecycle_run_is_active(tmp_path: Path): assert result.status == UpdateStatus.SKIPPED assert result.message == "scheduled update skipped: 1 CodeAlmanac job is active" assert runner.commands == [] - assert not (state_dir / "update.lock").exists() + + # Lock is released, so we can acquire it + lock = FileLock(str(state_dir / "update.lock")) + lock.acquire(timeout=0) + lock.release() def test_scheduled_update_skips_when_update_lock_is_held(tmp_path: Path): state_dir = tmp_path / ".codealmanac" state_dir.mkdir() - (state_dir / "update.lock").write_text( - '{"pid": 1, "created_at": "2099-01-01T00:00:00Z"}', - encoding="utf-8", - ) - runner = FakeCommandRunner(PackageCommandResult(exit_code=0)) - service = UpdatesService( - FakeMetadataProvider(PackageInstallMetadata(version="0.1.0", installer="uv")), - runner, - lock_path=state_dir / "update.lock", - database_path=state_dir / "codealmanac.db", - ) - - result = service.run( - RunUpdateRequest( - scheduled=True, - now=datetime(2026, 7, 6, tzinfo=UTC), + lock_path = state_dir / "update.lock" + + other_lock = FileLock(str(lock_path)) + other_lock.acquire() + + try: + runner = FakeCommandRunner(PackageCommandResult(exit_code=0)) + service = UpdatesService( + FakeMetadataProvider( + PackageInstallMetadata(version="0.1.0", installer="uv") + ), + runner, + lock_path=lock_path, + database_path=state_dir / "codealmanac.db", ) - ) - assert result.status == UpdateStatus.SKIPPED - assert result.message == "scheduled update skipped: update already in progress" - assert runner.commands == [] - assert (state_dir / "update.lock").exists() + result = service.run(RunUpdateRequest(scheduled=True)) + + assert result.status == UpdateStatus.SKIPPED + assert result.message == "scheduled update skipped: update already in progress" + assert runner.commands == [] + assert lock_path.exists() + finally: + other_lock.release() def test_scheduled_update_runs_smoke_after_success(tmp_path: Path): @@ -236,7 +244,10 @@ def test_scheduled_update_runs_smoke_after_success(tmp_path: Path): ("codealmanac", "doctor", "--json"), ] assert tuple(smoke.exit_code for smoke in result.smoke) == (0, 0) - assert not (tmp_path / ".codealmanac/update.lock").exists() + # Lock is released, so we can acquire it + lock = FileLock(str(tmp_path / ".codealmanac/update.lock")) + lock.acquire(timeout=0) + lock.release() def test_scheduled_update_fails_when_smoke_fails(tmp_path: Path): @@ -307,3 +318,82 @@ def write_run_record( ) if status == RunStatus.RUNNING: store.mark_running(record.run_id) + + +def test_update_lock_store_acquires_new_lock(tmp_path: Path): + lock_path = tmp_path / "update.lock" + store = UpdateLockStore() + + lease = store.acquire(lock_path) + assert lease is not None + assert lock_path.exists() + + lease.release() + + # Another lease can be acquired after release + lease2 = store.acquire(lock_path) + assert lease2 is not None + lease2.release() + + +def test_update_lock_store_refuses_held_lock(tmp_path: Path): + lock_path = tmp_path / "update.lock" + store = UpdateLockStore() + + # First acquisition + lease1 = store.acquire(lock_path) + assert lease1 is not None + + # Second acquisition immediately after should fail + lease2 = store.acquire(lock_path) + assert lease2 is None + + lease1.release() + + # After release, we can acquire again + lease3 = store.acquire(lock_path) + assert lease3 is not None + lease3.release() + + +def hold_lock_worker(lock_path_str: str, start_event, stop_event) -> None: + from filelock import FileLock + + lock = FileLock(lock_path_str) + lock.acquire() + start_event.set() + stop_event.wait(timeout=5) + lock.release() + + +def test_update_lock_store_multiprocessing(tmp_path: Path): + lock_path = tmp_path / "update.lock" + store = UpdateLockStore() + + import multiprocessing + + start_event = multiprocessing.Event() + stop_event = multiprocessing.Event() + + p = multiprocessing.Process( + target=hold_lock_worker, + args=(str(lock_path), start_event, stop_event), + ) + p.start() + + try: + # Wait for the worker to acquire the lock + assert start_event.wait(timeout=5) + + # Now, try to acquire the lock in this process. It should fail. + lease = store.acquire(lock_path) + assert lease is None + + finally: + stop_event.set() + p.join(timeout=5) + + # After the worker finishes (releasing the lock), we should be able to acquire it + lease2 = store.acquire(lock_path) + assert lease2 is not None + lease2.release() diff --git a/tests/test_viewer_service.py b/tests/test_viewer_service.py index 2c61d0e5..f5610c3d 100644 --- a/tests/test_viewer_service.py +++ b/tests/test_viewer_service.py @@ -45,6 +45,8 @@ def test_viewer_overview_search_and_topic_use_index_read_model( assert overview.featured_page.slug == "README" assert "auth-flow" in [page.slug for page in overview.pages] assert [page.slug for page in search.pages] == ["auth-flow"] + assert search.pages[0].matched_heading == "Auth Flow" + assert "Login checks" in (search.pages[0].excerpt or "") assert [page.slug for page in topic.pages] == ["auth-flow", "session-store"] diff --git a/tests/test_wiki_sections.py b/tests/test_wiki_sections.py new file mode 100644 index 00000000..40da32d2 --- /dev/null +++ b/tests/test_wiki_sections.py @@ -0,0 +1,69 @@ +from codealmanac.services.wiki.sections import project_sections + + +def test_projects_heading_hierarchy_and_exact_source_slices(): + body = ( + "Preamble sentence.\n\n" + "# Run Lifecycle\n\n" + "Introduction.\n\n" + "## Cancellation\n\n" + "Cancel a worker.\n\n" + "### Confirmation\n\n" + "Wait for exit.\n" + ) + + sections = project_sections(body, page_title="Run Lifecycle") + + assert [section.section_id for section in sections] == [ + "0000", + "0001", + "0002", + "0003", + ] + assert [section.heading_path for section in sections] == [ + ("Run Lifecycle",), + ("Run Lifecycle",), + ("Run Lifecycle", "Cancellation"), + ("Run Lifecycle", "Cancellation", "Confirmation"), + ] + assert sections[0].body == "Preamble sentence.\n\n" + assert sections[1].body == "\nIntroduction.\n\n" + assert sections[2].body == "\nCancel a worker.\n\n" + assert sections[3].body == "\nWait for exit.\n" + + +def test_markdown_parser_owns_code_blocks_and_setext_headings(): + body = ( + "# Top\n\n" + "```python\n# This is code, not a heading\n```\n\n" + "Setext child\n------------\n" + "Body.\n" + ) + + sections = project_sections(body, page_title="Top") + + assert len(sections) == 2 + assert "# This is code" in sections[0].body + assert sections[1].heading_path == ("Top", "Setext child") + assert sections[1].body == "Body.\n" + + +def test_duplicate_and_empty_headings_have_deterministic_distinct_ids(): + body = "# Page\n\n## Retry\n\n## Retry\n" + + first = project_sections(body, page_title="Page") + rebuilt = project_sections(body, page_title="Page") + + assert first == rebuilt + assert [section.section_id for section in first] == ["0000", "0001", "0002"] + assert first[1].heading_path == first[2].heading_path + assert first[1].body == "\n" + assert first[2].body == "" + + +def test_heading_free_page_still_has_one_searchable_section(): + sections = project_sections("Only prose.\n", page_title="Note") + + assert len(sections) == 1 + assert sections[0].heading_path == ("Note",) + assert sections[0].body == "Only prose.\n" diff --git a/uv.lock b/uv.lock index 36c69403..8cecaaf3 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 1 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -17,9 +17,9 @@ dependencies = [ { name = "pydantic" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/85/d6/4fb29bc1dff1fc41029caee56b7072c9ec1f3af28fc72ad2fc7ea59f446b/almanac_yoke-0.1.7.tar.gz", hash = "sha256:6876e41dbef580d99f7c2e2f1e4d1d244a620c1d8dab76f2aa5097af09a04410", size = 118706 } +sdist = { url = "https://files.pythonhosted.org/packages/85/d6/4fb29bc1dff1fc41029caee56b7072c9ec1f3af28fc72ad2fc7ea59f446b/almanac_yoke-0.1.7.tar.gz", hash = "sha256:6876e41dbef580d99f7c2e2f1e4d1d244a620c1d8dab76f2aa5097af09a04410", size = 118706, upload-time = "2026-07-11T19:37:25.632Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/ea/ea68b5adc3b87b25c6eac80cf10c16dd4a3193bfec91674f0a75ee902b7e/almanac_yoke-0.1.7-py3-none-any.whl", hash = "sha256:58547b5954dd10f582df6cad6caff6631ee8afb2c9db100042068893ad64b8d8", size = 138415 }, + { url = "https://files.pythonhosted.org/packages/42/ea/ea68b5adc3b87b25c6eac80cf10c16dd4a3193bfec91674f0a75ee902b7e/almanac_yoke-0.1.7-py3-none-any.whl", hash = "sha256:58547b5954dd10f582df6cad6caff6631ee8afb2c9db100042068893ad64b8d8", size = 138415, upload-time = "2026-07-11T19:37:24.135Z" }, ] [package.optional-dependencies] @@ -31,18 +31,18 @@ claude = [ name = "annotated-doc" version = "0.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] [[package]] name = "annotated-types" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] [[package]] @@ -53,18 +53,27 @@ dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831 } +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875 }, + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, ] [[package]] name = "attrs" version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055 } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] [[package]] @@ -75,18 +84,18 @@ dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571 } +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924 }, + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, ] [[package]] name = "certifi" version = "2026.6.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594 } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289 }, + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] [[package]] @@ -96,127 +105,127 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271 }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048 }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529 }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097 }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983 }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519 }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572 }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963 }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361 }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230 }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043 }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446 }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101 }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948 }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422 }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499 }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928 }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302 }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320 }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487 }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049 }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793 }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300 }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244 }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828 }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926 }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773 }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013 }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593 }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354 }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480 }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584 }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443 }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437 }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 }, +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328 }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061 }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031 }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239 }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589 }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733 }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652 }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229 }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552 }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806 }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316 }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274 }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468 }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460 }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330 }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828 }, - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627 }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008 }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303 }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282 }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595 }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986 }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711 }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036 }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998 }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056 }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537 }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176 }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723 }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085 }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819 }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915 }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234 }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042 }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706 }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727 }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882 }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860 }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564 }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276 }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238 }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189 }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352 }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024 }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869 }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541 }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634 }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384 }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133 }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257 }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851 }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393 }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251 }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609 }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014 }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979 }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238 }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110 }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824 }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103 }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194 }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827 }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168 }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018 }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958 }, +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] @@ -228,13 +237,13 @@ dependencies = [ { name = "mcp" }, { name = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/98/8fdab35ed9e1a36bc7afab4d390cc5002094a4950996c079da9aa4541cc4/claude_agent_sdk-0.2.110.tar.gz", hash = "sha256:538b548bac07a22f65686abab063a902ac76ba35989d0f073c942f96248e9fa3", size = 255632 } +sdist = { url = "https://files.pythonhosted.org/packages/bb/98/8fdab35ed9e1a36bc7afab4d390cc5002094a4950996c079da9aa4541cc4/claude_agent_sdk-0.2.110.tar.gz", hash = "sha256:538b548bac07a22f65686abab063a902ac76ba35989d0f073c942f96248e9fa3", size = 255632, upload-time = "2026-06-24T22:11:52.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/93/29d4fdaa13e69034faf8d3503df915b07c820e2c08e3d6a7515149cde5bb/claude_agent_sdk-0.2.110-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fed0e0f4804d9f9cff80ab7d1b44142ebd1046cdd29ca74caef4c92c35fff8d8", size = 64924533 }, - { url = "https://files.pythonhosted.org/packages/aa/03/b40bb673cd93cdc3928262c1be75fde34a7bed4bf2c2c20e04218e2005ea/claude_agent_sdk-0.2.110-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:62b23869d46cef6f6ff1d00ceaa5e846e2f1d297478421c835efb8fe99369d4f", size = 69704449 }, - { url = "https://files.pythonhosted.org/packages/f9/18/ab67cb5ce641333385bed55ed8e9665c00f7d30d1f6ab12f8463ddb7695f/claude_agent_sdk-0.2.110-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:324e49553c6303d6b267217dc2912652b97af2bc96503efd12095ae915b46b83", size = 74879555 }, - { url = "https://files.pythonhosted.org/packages/91/88/3627d7d14310cfec66977551263e219365244a906fc7ca1209fb0c3a6cec/claude_agent_sdk-0.2.110-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:56371dd7a2c66c0bd497dc0b3cab4193a228b196f600676393d69c0ecee37cfb", size = 75924237 }, - { url = "https://files.pythonhosted.org/packages/49/79/c9066c5c387d42c19a4b675ec1ff5219f8920cfda8ff8b527119fd69b774/claude_agent_sdk-0.2.110-py3-none-win_amd64.whl", hash = "sha256:4235d4de6d685a189c12612095ab192b759280ede1f3aed0c3e784d52c3555f9", size = 75448209 }, + { url = "https://files.pythonhosted.org/packages/aa/93/29d4fdaa13e69034faf8d3503df915b07c820e2c08e3d6a7515149cde5bb/claude_agent_sdk-0.2.110-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fed0e0f4804d9f9cff80ab7d1b44142ebd1046cdd29ca74caef4c92c35fff8d8", size = 64924533, upload-time = "2026-06-24T22:11:55.612Z" }, + { url = "https://files.pythonhosted.org/packages/aa/03/b40bb673cd93cdc3928262c1be75fde34a7bed4bf2c2c20e04218e2005ea/claude_agent_sdk-0.2.110-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:62b23869d46cef6f6ff1d00ceaa5e846e2f1d297478421c835efb8fe99369d4f", size = 69704449, upload-time = "2026-06-24T22:11:59.149Z" }, + { url = "https://files.pythonhosted.org/packages/f9/18/ab67cb5ce641333385bed55ed8e9665c00f7d30d1f6ab12f8463ddb7695f/claude_agent_sdk-0.2.110-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:324e49553c6303d6b267217dc2912652b97af2bc96503efd12095ae915b46b83", size = 74879555, upload-time = "2026-06-24T22:12:03.25Z" }, + { url = "https://files.pythonhosted.org/packages/91/88/3627d7d14310cfec66977551263e219365244a906fc7ca1209fb0c3a6cec/claude_agent_sdk-0.2.110-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:56371dd7a2c66c0bd497dc0b3cab4193a228b196f600676393d69c0ecee37cfb", size = 75924237, upload-time = "2026-06-24T22:12:07.183Z" }, + { url = "https://files.pythonhosted.org/packages/49/79/c9066c5c387d42c19a4b675ec1ff5219f8920cfda8ff8b527119fd69b774/claude_agent_sdk-0.2.110-py3-none-win_amd64.whl", hash = "sha256:4235d4de6d685a189c12612095ab192b759280ede1f3aed0c3e784d52c3555f9", size = 75448209, upload-time = "2026-06-24T22:12:11.283Z" }, ] [[package]] @@ -244,25 +253,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000 } +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243 }, + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] name = "codealmanac" -version = "0.4.4" +version = "0.4.7" source = { editable = "." } dependencies = [ { name = "almanac-yoke", extra = ["claude"] }, { name = "beautifulsoup4" }, { name = "charset-normalizer" }, { name = "fastapi" }, + { name = "filelock" }, { name = "httpx" }, { name = "humanfriendly" }, { name = "jsonlines" }, { name = "markdown-it-py" }, { name = "pathspec" }, + { name = "posthog" }, { name = "psutil" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -285,11 +296,13 @@ requires-dist = [ { name = "beautifulsoup4", specifier = ">=4.15.0" }, { name = "charset-normalizer", specifier = ">=3.4.7" }, { name = "fastapi", specifier = ">=0.138.1" }, + { name = "filelock", specifier = ">=3.12.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "humanfriendly", specifier = ">=10.0" }, { name = "jsonlines", specifier = ">=4.0" }, { name = "markdown-it-py", specifier = ">=4.2.0" }, { name = "pathspec", specifier = ">=1.1.1" }, + { name = "posthog", specifier = ">=7,<8" }, { name = "psutil", specifier = ">=7.2.2" }, { name = "pydantic", specifier = ">=2.8" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, @@ -310,9 +323,9 @@ dev = [ 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 } +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 }, + { 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]] @@ -322,47 +335,56 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100 }, - { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978 }, - { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422 }, - { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503 }, - { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779 }, - { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683 }, - { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874 }, - { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283 }, - { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844 }, - { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290 }, - { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612 }, - { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804 }, - { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026 }, - { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892 }, - { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835 }, - { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239 }, - { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593 }, - { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961 }, - { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145 }, - { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719 }, - { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209 }, - { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285 }, - { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441 }, - { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869 }, - { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948 }, - { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153 }, - { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947 }, - { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429 }, - { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968 }, - { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758 }, - { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863 }, - { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983 }, - { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173 }, - { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298 }, - { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338 }, - { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650 }, - { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820 }, - { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968 }, - { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547 }, +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] [[package]] @@ -376,18 +398,27 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/c9/5e8defe249899c0dc900643695fc07829a67fc88b4ff2cdb03fcbdbf5a4b/fastapi-0.138.1.tar.gz", hash = "sha256:96e3702dce09ee0dce48856135620d3d865ca684a79fe7513fd7b13a12f82862", size = 419646 } +sdist = { url = "https://files.pythonhosted.org/packages/8a/c9/5e8defe249899c0dc900643695fc07829a67fc88b4ff2cdb03fcbdbf5a4b/fastapi-0.138.1.tar.gz", hash = "sha256:96e3702dce09ee0dce48856135620d3d865ca684a79fe7513fd7b13a12f82862", size = 419646, upload-time = "2026-06-25T15:40:42.115Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/a9/69a6924f645eb4dd8cd625bf255b3625990eb3e14e073438a53c405dcd3e/fastapi-0.138.1-py3-none-any.whl", hash = "sha256:b994cae7ba8b82c976a728b544244de31333fa5f7d261f9a1dffe526444cae23", size = 129182, upload-time = "2026-06-25T15:40:40.771Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/94/00f2059e4835eace3ae8fde680b932c496f8ec7bdc99168dfa53fb2e6b79/filelock-3.29.7.tar.gz", hash = "sha256:5b481979797ae69e72f0b389d89a80bdd585c260c5b3f1fb9c0a5ba9bb3f195d", size = 71521, upload-time = "2026-07-08T05:46:58.716Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/a9/69a6924f645eb4dd8cd625bf255b3625990eb3e14e073438a53c405dcd3e/fastapi-0.138.1-py3-none-any.whl", hash = "sha256:b994cae7ba8b82c976a728b544244de31333fa5f7d261f9a1dffe526444cae23", size = 129182 }, + { url = "https://files.pythonhosted.org/packages/60/02/be4a57b60c7149b55b9e3b3c13f609cd8eb5307c751f22bd8fb8d262e75b/filelock-3.29.7-py3-none-any.whl", hash = "sha256:987db6f789a3a2a59f55081801b2b3697cb97e2a736b5f1a9e99b559285fbc51", size = 46036, upload-time = "2026-07-08T05:46:57.53Z" }, ] [[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 } +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 }, + { 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]] @@ -398,9 +429,9 @@ 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 } +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 }, + { 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]] @@ -411,9 +442,9 @@ dependencies = [ { name = "h11" }, { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/06/5c12df521b5322fb1114a83d46911b2fbcb8855ddb3a635f11c01a214af5/httpcore2-2.5.0.tar.gz", hash = "sha256:88aa170137c17328d5ac44234f9fd10706466d5fb347f3edac4d39b91137b09d", size = 64808 } +sdist = { url = "https://files.pythonhosted.org/packages/47/06/5c12df521b5322fb1114a83d46911b2fbcb8855ddb3a635f11c01a214af5/httpcore2-2.5.0.tar.gz", hash = "sha256:88aa170137c17328d5ac44234f9fd10706466d5fb347f3edac4d39b91137b09d", size = 64808, upload-time = "2026-06-25T14:16:56.472Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/a1/7564199d1a8728fe737b0a72e5b3f8d92dfe085a74ddf7cdd83bce5f206d/httpcore2-2.5.0-py3-none-any.whl", hash = "sha256:5ce35188de461d31e8d000bfb8ef8bf22c6c16587a211e5571deaa5e9bdf842a", size = 80330 }, + { url = "https://files.pythonhosted.org/packages/c9/a1/7564199d1a8728fe737b0a72e5b3f8d92dfe085a74ddf7cdd83bce5f206d/httpcore2-2.5.0-py3-none-any.whl", hash = "sha256:5ce35188de461d31e8d000bfb8ef8bf22c6c16587a211e5571deaa5e9bdf842a", size = 80330, upload-time = "2026-06-25T14:16:53.634Z" }, ] [[package]] @@ -426,18 +457,18 @@ dependencies = [ { 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 } +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 }, + { 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 = "httpx-sse" version = "0.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943 } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960 }, + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] [[package]] @@ -451,9 +482,9 @@ dependencies = [ { name = "truststore" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/e2/b5dedc0cf35aa65de5f541ccd30d2bc1fd7f1d43c9ab09f8ed9a7342317b/httpx2-2.5.0.tar.gz", hash = "sha256:e2df9cb4611021527ff8a675b1c320b610a2ec397acc8d6fe6e91df2d9b33c29", size = 83121 } +sdist = { url = "https://files.pythonhosted.org/packages/d0/e2/b5dedc0cf35aa65de5f541ccd30d2bc1fd7f1d43c9ab09f8ed9a7342317b/httpx2-2.5.0.tar.gz", hash = "sha256:e2df9cb4611021527ff8a675b1c320b610a2ec397acc8d6fe6e91df2d9b33c29", size = 83121, upload-time = "2026-06-25T14:16:57.491Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/22/859d8252dad9bc9adee34b52e62cde621ece07b042ccb2ab4da1be46695f/httpx2-2.5.0-py3-none-any.whl", hash = "sha256:3d2d4d9cf4b61f1a1f46a95947cfdb47e80cb56a2f91c6256ac8f58e4891df41", size = 76652 }, + { url = "https://files.pythonhosted.org/packages/31/22/859d8252dad9bc9adee34b52e62cde621ece07b042ccb2ab4da1be46695f/httpx2-2.5.0-py3-none-any.whl", hash = "sha256:3d2d4d9cf4b61f1a1f46a95947cfdb47e80cb56a2f91c6256ac8f58e4891df41", size = 76652, upload-time = "2026-06-25T14:16:55.23Z" }, ] [[package]] @@ -463,27 +494,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyreadline3", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702 } +sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794 }, + { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, ] [[package]] name = "idna" version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711 } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455 }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[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 } +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 }, + { 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]] @@ -493,9 +524,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/35/87/bcda8e46c88d0e34cad2f09ee2d0c7f5957bccdb9791b0b934ec84d84be4/jsonlines-4.0.0.tar.gz", hash = "sha256:0c6d2c09117550c089995247f605ae4cf77dd1533041d366351f6f298822ea74", size = 11359 } +sdist = { url = "https://files.pythonhosted.org/packages/35/87/bcda8e46c88d0e34cad2f09ee2d0c7f5957bccdb9791b0b934ec84d84be4/jsonlines-4.0.0.tar.gz", hash = "sha256:0c6d2c09117550c089995247f605ae4cf77dd1533041d366351f6f298822ea74", size = 11359, upload-time = "2023-09-01T12:34:44.187Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/62/d9ba6323b9202dd2fe166beab8a86d29465c41a0288cbe229fac60c1ab8d/jsonlines-4.0.0-py3-none-any.whl", hash = "sha256:185b334ff2ca5a91362993f42e83588a360cf95ce4b71a73548502bda52a7c55", size = 8701 }, + { url = "https://files.pythonhosted.org/packages/f8/62/d9ba6323b9202dd2fe166beab8a86d29465c41a0288cbe229fac60c1ab8d/jsonlines-4.0.0-py3-none-any.whl", hash = "sha256:185b334ff2ca5a91362993f42e83588a360cf95ce4b71a73548502bda52a7c55", size = 8701, upload-time = "2023-09-01T12:34:42.563Z" }, ] [[package]] @@ -508,9 +539,9 @@ dependencies = [ { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583 } +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630 }, + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] [[package]] @@ -520,9 +551,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "referencing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] [[package]] @@ -532,9 +563,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454 } +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687 }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] [[package]] @@ -557,82 +588,97 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501 } +sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620 }, + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, ] [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] name = "packaging" version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134 } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] name = "pathspec" version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180 } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328 }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[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 } +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 }, + { 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 = "posthog" +version = "7.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "distro" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/b1/a52ac5e432715e651b149803f0f0d766af20e8e3c0836049e32933601618/posthog-7.25.0.tar.gz", hash = "sha256:3a7aeab611ba48824e700314d5f81e00e0ff8a1c169a244c0fa18c6f7960f61f", size = 345904, upload-time = "2026-07-16T12:11:24.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/01/e828586e7d51ca693a522cb347a92f6ed26e3ca3a0d3916fe9ab5f1bebfd/posthog-7.25.0-py3-none-any.whl", hash = "sha256:63b7879cd066f6a621327db449f831392e6a79226892417dc20701f037ad34d3", size = 413751, upload-time = "2026-07-16T12:11:23.04Z" }, ] [[package]] name = "psutil" version = "7.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595 }, - { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082 }, - { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476 }, - { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062 }, - { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893 }, - { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589 }, - { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664 }, - { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087 }, - { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383 }, - { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210 }, - { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228 }, - { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284 }, - { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090 }, - { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859 }, - { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560 }, - { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997 }, - { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972 }, - { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266 }, - { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737 }, - { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617 }, +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] [[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 } +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 }, + { 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]] @@ -645,9 +691,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775 } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262 }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] @@ -657,72 +703,72 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158 }, - { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724 }, - { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742 }, - { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418 }, - { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274 }, - { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940 }, - { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516 }, - { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854 }, - { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306 }, - { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044 }, - { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133 }, - { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464 }, - { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823 }, - { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919 }, - { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604 }, - { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306 }, - { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906 }, - { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802 }, - { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446 }, - { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757 }, - { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275 }, - { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467 }, - { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417 }, - { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782 }, - { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782 }, - { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334 }, - { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986 }, - { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693 }, - { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819 }, - { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411 }, - { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079 }, - { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179 }, - { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926 }, - { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785 }, - { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733 }, - { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534 }, - { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732 }, - { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627 }, - { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141 }, - { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325 }, - { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990 }, - { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978 }, - { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354 }, - { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238 }, - { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251 }, - { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593 }, - { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226 }, - { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605 }, - { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777 }, - { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641 }, - { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404 }, - { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219 }, - { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594 }, - { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542 }, - { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146 }, - { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309 }, - { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736 }, - { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575 }, - { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624 }, - { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325 }, - { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527 }, - { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024 }, - { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696 }, - { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590 }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, ] [[package]] @@ -734,27 +780,27 @@ dependencies = [ { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700 } +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715 }, + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, ] [[package]] name = "pygments" version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "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 } +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 }, + { 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] @@ -766,9 +812,9 @@ crypto = [ name = "pyreadline3" version = "3.5.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf", size = 100368 } +sdist = { url = "https://files.pythonhosted.org/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf", size = 100368, upload-time = "2026-05-14T17:55:04.471Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d", size = 85243 }, + { url = "https://files.pythonhosted.org/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d", size = 85243, upload-time = "2026-05-14T17:55:03.262Z" }, ] [[package]] @@ -782,18 +828,18 @@ dependencies = [ { 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 } +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 }, + { 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 = "python-dotenv" version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] @@ -803,18 +849,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/e8/79cbe69864d44f3b48e70ebee0a872a7d5a4e7150c9f8577ed7a5beefff0/python_frontmatter-1.3.0.tar.gz", hash = "sha256:acc73e477a568dc2a25c9e130c6c68ae8daa8c204c8f7e813db47d6a7280dcf2", size = 8322 } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e8/79cbe69864d44f3b48e70ebee0a872a7d5a4e7150c9f8577ed7a5beefff0/python_frontmatter-1.3.0.tar.gz", hash = "sha256:acc73e477a568dc2a25c9e130c6c68ae8daa8c204c8f7e813db47d6a7280dcf2", size = 8322, upload-time = "2026-05-20T19:21:44.164Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/a3/17c284b4f4d8ad50f0f9ba70ad8fcc35c777aeafcdbbffdd91bbdc5ab379/python_frontmatter-1.3.0-py3-none-any.whl", hash = "sha256:9f7dd9260bec99044219159a329f64f039087f9d1a2124c9442556f2fe6f82ec", size = 10562 }, + { url = "https://files.pythonhosted.org/packages/a6/a3/17c284b4f4d8ad50f0f9ba70ad8fcc35c777aeafcdbbffdd91bbdc5ab379/python_frontmatter-1.3.0-py3-none-any.whl", hash = "sha256:9f7dd9260bec99044219159a329f64f039087f9d1a2124c9442556f2fe6f82ec", size = 10562, upload-time = "2026-05-20T19:21:43.323Z" }, ] [[package]] name = "python-multipart" version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881 } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042 }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]] @@ -822,64 +868,64 @@ name = "pywin32" version = "312" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877 }, - { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841 }, - { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901 }, - { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184 }, - { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298 }, - { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640 }, - { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928 }, - { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157 }, - { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598 }, - { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159 }, - { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293 }, - { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337 }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +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/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]] @@ -891,157 +937,172 @@ dependencies = [ { name = "rpds-py" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 } +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "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/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 }, + { 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 = "rpds-py" version = "2026.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691 }, - { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542 }, - { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180 }, - { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067 }, - { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509 }, - { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754 }, - { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189 }, - { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750 }, - { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576 }, - { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807 }, - { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187 }, - { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030 }, - { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185 }, - { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394 }, - { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753 }, - { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012 }, - { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203 }, - { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984 }, - { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815 }, - { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545 }, - { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828 }, - { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678 }, - { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811 }, - { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382 }, - { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832 }, - { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011 }, - { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431 }, - { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710 }, - { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454 }, - { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063 }, - { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510 }, - { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495 }, - { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454 }, - { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583 }, - { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919 }, - { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725 }, - { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255 }, - { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060 }, - { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960 }, - { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356 }, - { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319 }, - { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508 }, - { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504 }, - { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380 }, - { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976 }, - { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840 }, - { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282 }, - { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403 }, - { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055 }, - { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419 }, - { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848 }, - { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369 }, - { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673 }, - { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500 }, - { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978 }, - { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350 }, - { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486 }, - { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068 }, - { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600 }, - { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726 }, - { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587 }, - { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585 }, - { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479 }, - { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418 }, - { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123 }, - { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351 }, - { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827 }, - { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966 }, - { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680 }, - { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853 }, - { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715 }, - { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864 }, - { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430 }, - { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877 }, - { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933 }, - { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274 }, - { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763 }, - { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467 }, - { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689 }, - { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340 }, - { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179 }, - { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993 }, - { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909 }, - { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584 }, - { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357 }, - { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533 }, - { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204 }, - { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719 }, +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, ] [[package]] name = "ruamel-yaml" version = "0.19.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709 } +sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102 }, + { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, ] [[package]] name = "ruff" version = "0.15.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489 } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665 }, - { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649 }, - { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638 }, - { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227 }, - { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882 }, - { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808 }, - { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094 }, - { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176 }, - { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767 }, - { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132 }, - { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828 }, - { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418 }, - { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770 }, - { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698 }, - { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322 }, - { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274 }, - { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498 }, + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] [[package]] name = "sniffio" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] [[package]] name = "soupsieve" version = "2.8.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110 } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304 }, + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, ] [[package]] @@ -1052,9 +1113,9 @@ dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d2/1b/bc9e3e7a72dcdad7dc7888758f5d00f56f8909ed5cfdff822bd72bb4c520/sse_starlette-3.4.5.tar.gz", hash = "sha256:83072538bc211a2f68b7b0422226c4af3e9b62e106e07034664b832ca019842a", size = 35249 } +sdist = { url = "https://files.pythonhosted.org/packages/d2/1b/bc9e3e7a72dcdad7dc7888758f5d00f56f8909ed5cfdff822bd72bb4c520/sse_starlette-3.4.5.tar.gz", hash = "sha256:83072538bc211a2f68b7b0422226c4af3e9b62e106e07034664b832ca019842a", size = 35249, upload-time = "2026-06-20T17:36:58.322Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/75/c88d3f5dafd59c791da1ce27650d30bf5b70cbf1cbf01cd00e5f9e360915/sse_starlette-3.4.5-py3-none-any.whl", hash = "sha256:e71bad53323f65573c3864a6c3bd0c1eb6e5f092b2e48082b0c35927d19ca296", size = 16518 }, + { url = "https://files.pythonhosted.org/packages/78/75/c88d3f5dafd59c791da1ce27650d30bf5b70cbf1cbf01cd00e5f9e360915/sse_starlette-3.4.5-py3-none-any.whl", hash = "sha256:e71bad53323f65573c3864a6c3bd0c1eb6e5f092b2e48082b0c35927d19ca296", size = 16518, upload-time = "2026-06-20T17:36:56.729Z" }, ] [[package]] @@ -1065,27 +1126,27 @@ dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240 } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632 }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] name = "truststore" version = "0.10.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169 } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660 }, + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, + { 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]] @@ -1095,9 +1156,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[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/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, + { 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" }, ] [[package]] @@ -1108,7 +1178,7 @@ dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284 } +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376 }, + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, ]