From 7cb9dc36867a30144c30611a6814434e90d5cb65 Mon Sep 17 00:00:00 2001 From: zendaya Date: Fri, 1 May 2026 15:35:06 -0700 Subject: [PATCH 1/6] feat: FlightDeck 1.0.1 package and v1 rollout readiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship the local-first CLI, schemas, tests, and CI. Slim-repo docs link to canonical flightdeckdev/flightdeck main. OpenTelemetry is optional-only. Also: pytest basetemp under .tmp/pytest for Windows, Python 3.13–3.14 in CI, ruff 0.15.12 aligned with ruff-pre-commit, pre-commit-hooks v5, .gitattributes LF for golden bundle, CHANGELOG 1.0.1 section and empty Unreleased, RELEASE_NOTES v1.0.1 patch notes, README quickstart_smoke first. Co-authored-by: Cursor --- .cursorrules | 7 + .gitattributes | 2 + .github/CODEOWNERS | 2 + .github/ISSUE_TEMPLATE/bug_report.yml | 29 + .github/ISSUE_TEMPLATE/feature_request.yml | 34 + .github/PULL_REQUEST_TEMPLATE.md | 21 + .github/workflows/ci.yml | 79 ++ .gitignore | 31 + .pre-commit-config.yaml | 17 + AGENTS.md | 94 +++ CHANGELOG.md | 199 +++++ CLAUDE.md | 33 + CONTRIBUTING.md | 59 ++ DEVELOPMENT.md | 88 ++ LICENSE | 1 + NOTICE | 14 + README.md | 130 +++ RELEASE_NOTES.md | 49 ++ ROADMAP.md | 29 + SECURITY.md | 35 + VERSIONING.md | 34 + examples/quickstart/README.md | 16 + examples/quickstart/baseline-events.jsonl | 1 + .../baseline-release/prompts/system.md | 1 + .../quickstart/baseline-release/release.yaml | 22 + examples/quickstart/candidate-events.jsonl | 1 + .../candidate-release/prompts/system.md | 1 + .../quickstart/candidate-release/release.yaml | 22 + examples/quickstart/policy.yaml | 8 + examples/quickstart/pricing-baseline.yaml | 6 + examples/quickstart/pricing-candidate.yaml | 6 + pyproject.toml | 76 ++ schemas/v1/policy.schema.json | 95 +++ schemas/v1/pricing_table.schema.json | 66 ++ schemas/v1/release.schema.json | 396 +++++++++ schemas/v1/run_event.schema.json | 249 ++++++ scripts/generate_schemas.py | 23 + scripts/quickstart_smoke.py | 89 ++ scripts/smoke.sh | 34 + src/flightdeck/__init__.py | 3 + src/flightdeck/bundle.py | 68 ++ src/flightdeck/cli/__init__.py | 0 src/flightdeck/cli/main.py | 718 ++++++++++++++++ src/flightdeck/config.py | 40 + src/flightdeck/doctor.py | 59 ++ src/flightdeck/ledger.py | 242 ++++++ src/flightdeck/models.py | 214 +++++ src/flightdeck/sdk/__init__.py | 1 + src/flightdeck/sdk/client.py | 25 + src/flightdeck/server/__init__.py | 1 + src/flightdeck/server/app.py | 46 ++ src/flightdeck/server/routes/__init__.py | 0 src/flightdeck/storage.py | 632 ++++++++++++++ tests/conftest.py | 25 + tests/fixtures/golden_bundle/prompts/s.md | 2 + tests/fixtures/golden_bundle/release.yaml | 16 + tests/fixtures/json/policy_minimal_v1.json | 4 + .../json/pricing_table_minimal_v1.json | 11 + .../json/release_artifact_minimal_v1.json | 24 + tests/fixtures/json/run_event_minimal_v1.json | 28 + tests/test_bundle_checksum.py | 38 + tests/test_bundle_golden_fixture.py | 30 + tests/test_cli.py | 19 + tests/test_doctor.py | 131 +++ tests/test_examples_parse.py | 43 + tests/test_ledger.py | 91 +++ tests/test_quickstart_smoke.py | 19 + tests/test_release_verify.py | 56 ++ tests/test_schemas.py | 40 + tests/test_sdk_client.py | 52 ++ tests/test_server_ingest.py | 252 ++++++ tests/test_spine.py | 769 ++++++++++++++++++ 72 files changed, 5798 insertions(+) create mode 100644 .cursorrules create mode 100644 .gitattributes create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md create mode 100644 DEVELOPMENT.md create mode 100644 NOTICE create mode 100644 README.md create mode 100644 RELEASE_NOTES.md create mode 100644 ROADMAP.md create mode 100644 SECURITY.md create mode 100644 VERSIONING.md create mode 100644 examples/quickstart/README.md create mode 100644 examples/quickstart/baseline-events.jsonl create mode 100644 examples/quickstart/baseline-release/prompts/system.md create mode 100644 examples/quickstart/baseline-release/release.yaml create mode 100644 examples/quickstart/candidate-events.jsonl create mode 100644 examples/quickstart/candidate-release/prompts/system.md create mode 100644 examples/quickstart/candidate-release/release.yaml create mode 100644 examples/quickstart/policy.yaml create mode 100644 examples/quickstart/pricing-baseline.yaml create mode 100644 examples/quickstart/pricing-candidate.yaml create mode 100644 pyproject.toml create mode 100644 schemas/v1/policy.schema.json create mode 100644 schemas/v1/pricing_table.schema.json create mode 100644 schemas/v1/release.schema.json create mode 100644 schemas/v1/run_event.schema.json create mode 100644 scripts/generate_schemas.py create mode 100644 scripts/quickstart_smoke.py create mode 100644 scripts/smoke.sh create mode 100644 src/flightdeck/__init__.py create mode 100644 src/flightdeck/bundle.py create mode 100644 src/flightdeck/cli/__init__.py create mode 100644 src/flightdeck/cli/main.py create mode 100644 src/flightdeck/config.py create mode 100644 src/flightdeck/doctor.py create mode 100644 src/flightdeck/ledger.py create mode 100644 src/flightdeck/models.py create mode 100644 src/flightdeck/sdk/__init__.py create mode 100644 src/flightdeck/sdk/client.py create mode 100644 src/flightdeck/server/__init__.py create mode 100644 src/flightdeck/server/app.py create mode 100644 src/flightdeck/server/routes/__init__.py create mode 100644 src/flightdeck/storage.py create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/golden_bundle/prompts/s.md create mode 100644 tests/fixtures/golden_bundle/release.yaml create mode 100644 tests/fixtures/json/policy_minimal_v1.json create mode 100644 tests/fixtures/json/pricing_table_minimal_v1.json create mode 100644 tests/fixtures/json/release_artifact_minimal_v1.json create mode 100644 tests/fixtures/json/run_event_minimal_v1.json create mode 100644 tests/test_bundle_checksum.py create mode 100644 tests/test_bundle_golden_fixture.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_doctor.py create mode 100644 tests/test_examples_parse.py create mode 100644 tests/test_ledger.py create mode 100644 tests/test_quickstart_smoke.py create mode 100644 tests/test_release_verify.py create mode 100644 tests/test_schemas.py create mode 100644 tests/test_sdk_client.py create mode 100644 tests/test_server_ingest.py create mode 100644 tests/test_spine.py diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..e654732 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,7 @@ +# FlightDeck — Cursor + +Read **`AGENTS.md`** for mission, non-goals, public contracts, engineering rules, verification, and docs policy. **`CLAUDE.md`** is a short index for Claude Code / Cursor. + +Quick verify: `python -m ruff check src tests`, `python -m pytest`, `python scripts/quickstart_smoke.py` (on Windows, `py -3` if needed). + +Normative v1 direction: https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md · backlog: https://github.com/flightdeckdev/flightdeck/blob/main/docs/v1-next-steps.md · CLI: https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..79bbdf7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Golden bundle checksum is sensitive to line endings on checkout (see CHANGELOG 0.7.0). +tests/fixtures/golden_bundle/** text eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..4d32943 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Team must exist under https://github.com/orgs/flightdeckdev/teams (or replace with @username). +* @flightdeckdev/maintainers diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..2055935 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,29 @@ +name: Bug report +description: Report a reproducible problem with FlightDeck. +title: "bug: " +labels: ["bug"] +body: + - type: textarea + id: summary + attributes: + label: Summary + description: What happened? + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Reproduction + description: Commands, inputs, and output needed to reproduce. + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: FlightDeck version or commit. + - type: textarea + id: environment + attributes: + label: Environment + description: OS, Python version, and relevant setup details. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..e2e7df0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,34 @@ +name: Feature request +description: Propose a focused FlightDeck capability. +title: "feat: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What release safety problem does this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal + description: What should FlightDeck do? + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - release artifact + - run event + - safety ledger + - pricing + - policy + - CLI + - docs + - other + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..4127a89 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,21 @@ +## Summary + +## Why + +## Changes + +## Validation + +- [ ] `python -m ruff check src tests` +- [ ] `python -m pytest` +- [ ] CLI smoke test, if relevant + +## Schema / Storage Impact + +- [ ] None +- [ ] Schema change +- [ ] Storage change + +## Risk + +## Notes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..66b9f4f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install + run: python -m pip install -e ".[dev]" + + - name: Lint + run: python -m ruff check src tests + + - name: Test + run: python -m pytest + + - name: JSON Schemas drift check + run: | + python scripts/generate_schemas.py + git diff --exit-code schemas/ + + - name: Quickstart smoke (cross-platform) + run: python scripts/quickstart_smoke.py + + - name: CLI smoke + run: flightdeck --help + + test-windows: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install + run: python -m pip install -e ".[dev]" + + - name: Lint + run: python -m ruff check src tests + + - name: Test + run: python -m pytest + + - name: JSON Schemas drift check + run: | + python scripts/generate_schemas.py + git diff --exit-code schemas/ + + - name: Quickstart smoke (cross-platform) + run: python scripts/quickstart_smoke.py + + - name: CLI smoke + run: flightdeck --help diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6e6c91 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +pytest-cache-files-*/ +.ruff_cache/ +.tmp/ +.venv/ +build/ +dist/ +*.egg-info/ +.coverage +htmlcov/ +.DS_Store +Thumbs.db + +# Local FlightDeck workspace (SQLite ledger + local config); never commit. +.flightdeck/ + +# Environment and secrets +.env +.env.* +*.pem +*.p12 +*.pfx +secrets/ +private/ + +# Common credential filenames (adjust if you add fixtures with similar names). +*credentials*.json +*service-account*.json +google-credentials.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e7f40cb --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-ast + - id: check-merge-conflict + - id: check-yaml + args: + - --allow-multiple-documents + - id: debug-statements + - id: end-of-file-fixer + - id: trailing-whitespace + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.12 + hooks: + - id: ruff diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a58e8f1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,94 @@ +# AGENTS.md + +## Repository model (research vs canonical) + +This tree is usually a **personal-account research repo** (`origin` → your GitHub user): local experimentation, WIP, and broad refactors are acceptable. + +**Organization repos** — canonical product: **[github.com/flightdeckdev/flightdeck](https://github.com/flightdeckdev/flightdeck)** under [flightdeckdev](https://github.com/flightdeckdev). Only **relevant**, standards-meeting changes (tests, ruff, no secrets, changelog/version when releasing) should be pushed or PR’d there—typically via a second remote **`org`** (workflow: [git remotes](https://github.com/flightdeckdev/flightdeck/blob/main/docs/git-remotes.md)). + +When implementing features, prefer **small, PR-shaped slices** that could ship to an org repo without extra cleanup. Do not conflate “saved in research” with “ready for org push.” + +Extended maintainer docs (research workflow, org checklist, canonical publish) live on **`main`** in that repository (for example [research workflow](https://github.com/flightdeckdev/flightdeck/blob/main/docs/research-workflow.md), [GitHub organization](https://github.com/flightdeckdev/flightdeck/blob/main/docs/github-organization.md)). This clone may omit those paths to stay small. Claude Code / short entrypoint: **`CLAUDE.md`**. + +## Mission + +FlightDeck is AI Release Governance for production agents. The core product promise is +trustworthy release safety: version releases, ingest runtime evidence, compare diffs, and gate +promotion with policy. + +## Current Wedge + +Economic and operational safety for AI releases. + +## Non-goals + +Do not add: + +- prompt IDEs +- agent frameworks +- dashboards before CLI workflow is proven +- gateways/proxies by default +- compliance scanners +- fine-tuning ops +- broad plugin systems + +## Public contracts + +Treat these as **stable API** unless a change explicitly marks an experimental path: + +- **CLI:** synopsis, flags, exit codes — canonical reference: **[docs/cli.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md)** (normative for scripting). +- **On-disk / wire:** `release.yaml`, run events, pricing imports, policy shape — **`schemas/`** (generated; drift caught in CI), **[docs/spec.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec.md)** (0.x snapshot of what the code does today). +- **v1 / GA direction** (migrations, checksums, trust boundaries, defaults): **[docs/spec-v1-forward.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md)**. Prefer updating the forward spec for new v1 contracts; avoid expanding **`docs/spec.md`** as the rolling GA target. +- **Backlog and review status:** **[docs/v1-next-steps.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/v1-next-steps.md)**. + +## Engineering rules + +- Keep changes small and behavior-focused. +- Preserve the local-first CLI workflow. +- Treat schemas and CLI behavior as public contracts. +- Add tests for every behavior change. +- Prefer boring, explicit code over clever abstractions. +- Do not introduce empty enterprise scaffolding. +- Do not move modules into packages/apps until there is a real release boundary. + +## Before large or multi-file changes + +Do a short review pass for: + +- **Contract drift** (CLI, JSON/YAML, SQLite columns consumers rely on). +- **Trust boundaries** (diff, pricing, policy, promotion, serve host binding). +- **Cross-platform ergonomics** (Windows paths, line endings on fixtures, temp dirs). + +## Verification + +Run before finalizing changes: + +```bash +python -m ruff check src tests +python -m pytest +python scripts/quickstart_smoke.py +``` + +On **Windows**, use `py -3` in place of `python` if that is how your environment is set up. If pytest temp dirs fail with permissions, see **`DEVELOPMENT.md`** / **`tests/conftest.py`**. + +**CI bar** (same commands locally before a PR): **`ruff check`**, **`pytest`**, **`scripts/generate_schemas.py`** + no **`schemas/`** diff, **`scripts/quickstart_smoke.py`**, **`flightdeck --help`**. + +Use a repo-local temp directory if the OS temp directory is restricted. + +## Product doctrine + +A feature must strengthen at least one: + +- release artifact integrity +- runtime evidence +- safety ledger accuracy +- policy-gated promotion +- audit history +- developer onboarding + +If it does not, it waits. + +## Docs rules + +Public docs explain implemented behavior and near-term roadmap. Internal product strategy, legal +notes, and fundraising/customer discovery material do not belong in this repo. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5c4f18d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,199 @@ +# Changelog + +All notable changes to FlightDeck will be documented in this file. + +This project follows [Semantic Versioning](https://semver.org/). From **v1.0.0**, documented CLI behavior (**[docs/cli.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md)** on the canonical **`main`** branch), committed **`schemas/v1/`**, and **`POST /v1/events`** payloads with **`api_version` `v1`** are treated as stable public contracts unless a release notes a semver-major bump. + +## Unreleased + +Nothing yet. + +## 1.0.1 - 2026-05-01 + +### Added + +- **`.gitattributes`:** force **LF** for **`tests/fixtures/golden_bundle/`** so checkout line endings do not change the pinned bundle digest (matches the **0.7.0** changelog intent). + +### Changed + +- **Slim distribution:** this repository omits the full in-tree **`docs/`** tree, org mirror scripts, and **`verify-repo-standards`** wrappers. Narrative docs and maintainer runbooks live on **[github.com/flightdeckdev/flightdeck](https://github.com/flightdeckdev/flightdeck)**; in-repo links now point there where applicable. +- **`pyproject.toml`:** OpenTelemetry packages are **optional** only (**`telemetry`** / **`all`** extras); the default install matches the **1.0.0** dependency story (core does not import OpenTelemetry). +- **`.pre-commit-config.yaml`:** **ruff** replaces **black** / **isort**; **`ruff-pre-commit`** pinned to **v0.15.12** to match **`dev`** (**`ruff==0.15.12`**). +- **CI:** Python **3.13** and **3.14** added to the Ubuntu and Windows matrices. +- **`pyproject.toml`:** default **`pytest --basetemp=.tmp/pytest`** so local runs avoid Windows **`PermissionError`** on **`%TEMP%\pytest-of-*`**. +- **`pre-commit-hooks`:** bumped to **v5.0.0**. + +### Removed + +- **`tests/test_sync_export_public.py`** (depended on export tooling not shipped in this tree). + +## 1.0.0 - 2026-04-30 + +### Added + +- **v1.0.0 GA freeze** narrative: **`RELEASE_NOTES.md`**, **`docs/spec-v1-forward.md`**, **`docs/v1-next-steps.md`** (archived internal planning under **`docs/reviews/`** remains **development-clone only**). + +### Changed + +- **v1.0.0 GA freeze:** stable contracts for the local-first spine summarized in **[RELEASE_NOTES.md](RELEASE_NOTES.md)** (trust boundaries, SQLite migrations, payload versioning). +- **`pyproject.toml`**: **Development Status :: 5 - Production/Stable** on PyPI classifiers. +- **Dependencies:** **`opentelemetry-api`**, **`opentelemetry-sdk`**, and **`opentelemetry-exporter-otlp`** removed from default installs; added optional **`telemetry`** extra (included in **`all`**) for forward OTLP work — core code did not import OpenTelemetry. + +## 0.9.0 - 2026-04-30 + +### Added + +- **[RELEASE_NOTES.md](RELEASE_NOTES.md)**: maintainer-facing trust boundaries, SQLite upgrades, pre-1.0 vs **v1.0.0** freeze intent, payload **`api_version`** behavior. +- **`tests/fixtures/json/`**: minimal golden JSON for **`RunEvent`**, **`ReleaseArtifact`**, **`PricingTable`**, **`Policy`**; **`tests/test_schemas.py`** validates each fixture against Pydantic models. +- **HTTP ingest tests:** **`POST /v1/events`** rejects empty **`api_version`**, wrong casing (**`V1`**), JSON **`null`**; accepts omitted **`api_version`** (defaults to **`v1`**); stable **`detail`** string for **`v2`** rejections. +- **[CLAUDE.md](CLAUDE.md)**: short agent entry (must-read table, verify commands, Windows note). +- **0.9 → 1.0** sequencing captured in **`docs/v1-next-steps.md`** (detailed milestone plans archived under **`docs/reviews/`** in maintainer clones only). + +### Changed + +- **[AGENTS.md](AGENTS.md)**: public contracts section, large-change review checklist, expanded verification (including **`quickstart_smoke.py`**), PR-shaped slice guidance, pointers to **`CLAUDE.md`** and forward spec. +- **[.cursorrules](.cursorrules)**: slimmed to defer to **`AGENTS.md`** / **`CLAUDE.md`** and the same verify bar. +- **[VERSIONING.md](VERSIONING.md)**: database migrations describe shipped numbered SQLite migrations (replacing stale “future work” wording); **Approaching 1.0.0** pointer to **`RELEASE_NOTES.md`** / **`docs/v1-next-steps.md`**. + +## 0.8.0 - 2026-04-30 + +### Added + +- **[docs/cli.md](docs/cli.md)**: CLI reference (synopsis, flags, exit codes, pointers to quickstart examples). +- **`scripts/quickstart_smoke.py`**: cross-platform quickstart smoke (**no bash**): temp workspace, Python placeholder substitution, **`release verify`**, **`doctor`**. +- **CI:** run quickstart smoke on **Ubuntu** and **Windows** matrix jobs (alongside pytest and schema drift). +- **Tests:** `tests/test_quickstart_smoke.py` exercises the smoke script. +- **0.8 milestone planning** (CLI + CI): archived under **`docs/reviews/`** in development clones; shipped artifacts are **`docs/cli.md`** and **`scripts/quickstart_smoke.py`** above. + +### Changed + +- **[docs/quickstart.md](docs/quickstart.md)**: recommend **`python scripts/quickstart_smoke.py`** on Windows; bash flow kept as optional. +- **[docs/architecture.md](docs/architecture.md)**: deferred section updated for shipped SDK / rollback / serve / doctor / verify. +- **`scripts/verify-repo-standards.sh`** / **`.ps1`**: run **`quickstart_smoke.py`** after pytest (same bar as CI). + +## 0.7.0 - 2026-04-30 + +### Added + +- **`flightdeck release verify --path …`**: compares the checksum stored at registration with **`bundle_checksum`** on a supplied directory or `release.yaml` file; **exit code 2** on mismatch (**1** for normal CLI errors). +- **Committed golden bundle** `tests/fixtures/golden_bundle/` with a **pinned SHA-256** asserted in CI (Linux + Windows). +- **`.gitattributes`**: force **LF** for the golden fixture path so line-ending normalization on checkout does not change the digest. +- **0.7 milestone planning** (golden bundle / verify): archived under **`docs/reviews/`** in development clones; see **`tests/fixtures/golden_bundle/`** and **`flightdeck release verify`** above. + +### Changed + +- **`flightdeck.bundle`**: skip **symlink** files when hashing bundles (determinism + safety); POSIX test in **`tests/test_bundle_golden_fixture.py`** (skipped on Windows where symlink creation is often unavailable). +- **CI:** run **`python scripts/generate_schemas.py`** then **`git diff --exit-code schemas/`** on Ubuntu and Windows to catch hand-edited schema drift. + +## 0.6.0 - 2026-04-30 + +### Added + +- **Apache License, Version 2.0:** root **`LICENSE`** (from [apache.org](https://www.apache.org/licenses/LICENSE-2.0.txt)), **`NOTICE`**, and **`pyproject.toml`** `license = "Apache-2.0"` aligned with the canonical org repo [flightdeckdev/flightdeck](https://github.com/flightdeckdev/flightdeck). +- **SQLite migration 3:** `release_actions.audit_seq` (backfilled in `created_at` order), **`UNIQUE`** index **`idx_release_actions_audit_seq`**, and automatic assignment on insert via **`Storage._next_audit_seq`**. +- **`PromotionRecord.audit_seq`**: optional on write; populated when listing actions from storage. +- **`Storage.check_release_actions_audit_seq()`** and **`flightdeck doctor`** extension: verifies **contiguous** non-null **`audit_seq`** values `1..max` (gap / tamper hint per forward spec). + +### Changed + +- **README** / **[docs/github-organization.md](docs/github-organization.md)** / **[docs/git-remotes.md](docs/git-remotes.md):** point at **`https://github.com/flightdeckdev/flightdeck`**. + +## 0.5.1 - 2026-04-30 + +### Added + +- **`flightdeck doctor`**: read-only checks for **SQLite schema migrations** (through `LATEST_SCHEMA_MIGRATION_VERSION`) and **promoted release pointers** (each `promoted_releases.release_id` exists in `releases`). +- **`flightdeck.storage`**: `list_applied_migrations()`, `list_promoted_pointers()`, and **`LATEST_SCHEMA_MIGRATION_VERSION`** (keep in sync when adding migrations). + +## 0.5.0 - 2026-04-30 + +### Added + +- **[docs/v1-next-steps.md](docs/v1-next-steps.md)**: Maintainer v1 gap analysis (P0/P1/P2), milestone sequencing (0.5–1.0), and risk callouts; tracks implementation status as work lands. +- **`flightdeck serve`**: warns when `--host` is not loopback (trust boundary; see forward spec §4). +- **`POST /v1/events`**: rejects unsupported `api_version` before Pydantic with a clear 400 detail. +- **Ledger tests** (`tests/test_ledger.py`): `diff_releases` rejects cross-agent and mixed-agent run batches. + +### Changed + +- **Implicit default policy** (no `flightdeck policy set`): `require_high_diff_confidence` now defaults to **`true`**, matching the `Policy` model and **v1 GA** direction. Quickstart and tests keep explicit **`require_high_diff_confidence: false`** where low-sample demos need it. +- **`diff_releases`**: enforces a single shared `agent_id` across baseline and candidate run events when both sides are non-empty (defense in depth vs CLI-only checks). +- **`Storage.insert_release`**: uses **`transaction()`** for the same atomic discipline as promotion paths. +- **CLI** `release diff`, `release promote`, and `release rollback`: surface `ValueError` from `diff_releases` as `ClickException`. + +## 0.4.2 - 2026-04-30 + +### Added + +- **[docs/git-remotes.md](docs/git-remotes.md)**: configure **`origin`** (personal research) vs **`org`** ([flightdeckdev](https://github.com/flightdeckdev) canonical), with everyday `git push` examples. + +### Changed + +- **Research workflow docs** ([docs/research-workflow.md](docs/research-workflow.md), [RESEARCH.md](RESEARCH.md), [AGENTS.md](AGENTS.md), [.cursorrules](.cursorrules), [docs/github-organization.md](docs/github-organization.md)) now state explicitly: **personal account** = research clone; **org** = user-facing canonical. + +## 0.4.1 - 2026-04-30 + +### Added + +- **[docs/github-organization.md](docs/github-organization.md)** for the **[flightdeckdev](https://github.com/flightdeckdev)** org: when to add repos, pre-push checklist, private-file policy. +- **`scripts/verify-repo-standards.sh`** / **`.ps1`**: run ruff + pytest before pushing. + +### Changed + +- **`.gitignore`**: ignore **`.flightdeck/`** (local DB/config), **`private/`**, **`secrets/`**, common cert/credential patterns, **`Thumbs.db`**. +- **Release bundle checksum** moved to **`flightdeck.bundle`**: LF normalization for text-like extensions so CRLF vs LF does not change the digest; `.git` / `__pycache__` under a bundle are excluded from hashing. +- **SQLite migration 2**: index on `run_events(release_id, timestamp)` for ledger queries. + +### Security / hygiene + +- **[SECURITY.md](SECURITY.md)** and **[CONTRIBUTING.md](CONTRIBUTING.md)** expanded with secret/local-path guidance and link to the org push gate doc. + +## 0.4.0 - 2026-04-30 + +### Added + +- **Forward v1 specification** (`docs/spec-v1-forward.md`): normative direction for v1 GA (versioning, migrations, bundle checksum canonicalization, trust boundaries, diff/policy defaults, SDK testing discipline). `docs/spec.md` remains the frozen 0.x implementation narrative; new guarantees land here first. +- **SDK unit test** using `httpx.MockTransport` so the Python client is covered without relying on sync ASGI transport quirks. + +### Changed + +- README status and documentation index now point at the forward v1 spec and reflect shipped local HTTP + minimal SDK. + +## 0.3.0 - 2026-04-30 + +### Added + +- Local HTTP ingestion service: `flightdeck serve` with `POST /v1/events`. +- Minimal HTTP client helper: `flightdeck.sdk.client.FlightdeckClient`. +- `flightdeck release rollback` with the same policy gate + audit trail as `promote`. +- Append-only pricing import audit log (`pricing_import_audit`) and required `--reason` for `pricing import --replace`. +- Public JSON Schemas under `schemas/v1/` plus `scripts/generate_schemas.py`. +- Policy can require HIGH diff confidence (`require_high_diff_confidence`, default `true` on explicit policies). + +### Fixed + +- Atomic promotion: audit record + promoted pointer update now share a single DB transaction. +- SQLite connections enable WAL + busy timeout to reduce Windows locking issues. +- Safer `--window` parsing errors are surfaced as `ClickException` (no tracebacks). + +## 0.2.0 - 2026-04-30 + +### Added + +- Local release registry (`flightdeck release register`, `flightdeck release list`, `flightdeck release show`). +- Run event ingestion (`flightdeck runs ingest`). +- Trusted `flightdeck release diff` with confidence labels, explicit `--window`, and ASCII `delta` output. +- Per-release pricing for diffs (baseline and candidate are costed against their own `pricing_reference`). +- Cross-agent diff rejection by default. +- Immutable pricing table import with explicit `--replace` and `flightdeck pricing show`. +- Active policy object (`flightdeck policy set`, `flightdeck policy show`) used for diff evaluation and promotion. +- Policy-gated `flightdeck release promote` with required `--reason` and `flightdeck release history`. + +### Notes + +- Cost estimates are **model token costs** only; tool spend pricing is not implemented yet. + +### Fixed + +- Windows: avoid pytest temp-dir permission issues by redirecting `TEMP`/`TMP` into a repo-local `.tmp/` + directory during pytest (see `tests/conftest.py`), with an opt-out via `FLIGHTDECK_USE_SYSTEM_TEMP=1`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5c56eba --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,33 @@ +# CLAUDE.md + +Short entry for **Claude Code**, **Cursor**, and similar agents. **Authoritative policy:** root **`AGENTS.md`** (mission, non-goals, contracts, verification, doctrine). + +Canonical repository (full **`docs/`** tree and org workflows): **[github.com/flightdeckdev/flightdeck](https://github.com/flightdeckdev/flightdeck)** (`main`). + +## Read first + +| Topic | Location | +|--------|------| +| Agent / contributor rules | `AGENTS.md` | +| Setup and local demo | `DEVELOPMENT.md` | +| CLI flags and exit codes | [docs/cli.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md) (canonical repo) | +| v1 direction | [docs/spec-v1-forward.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md) | +| Shipped 0.x behavior snapshot | [docs/spec.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec.md) | +| Backlog and milestone status | [docs/v1-next-steps.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/v1-next-steps.md) | +| GA / release notes | `RELEASE_NOTES.md`, `CHANGELOG.md` | +| Org publish & staging (maintainer) | [docs/github-organization.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/github-organization.md) | +| Repo layout & CODEOWNERS | `.github/CODEOWNERS`, [docs/github-organization.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/github-organization.md) | + +## Verify before you finish + +```bash +python -m ruff check src tests +python -m pytest +python scripts/quickstart_smoke.py +``` + +**Windows:** if `python` is not on `PATH`, use `py -3` for the same commands. + +## Repo shape + +Python package under `src/flightdeck/`. Tests in `tests/`. Examples in `examples/quickstart/`. JSON Schemas under `schemas/` (regenerate with `python scripts/generate_schemas.py` when models change). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6f7bb7e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing + +FlightDeck is **v1.0.0+** on a narrow local-first spine; changes should meet production infrastructure standards. + +Contributions are accepted under the **Apache License, Version 2.0** (see **`LICENSE`**). The canonical tree is [github.com/flightdeckdev/flightdeck](https://github.com/flightdeckdev/flightdeck). + +Human and AI contributors: follow **[AGENTS.md](AGENTS.md)** (full rules). For a short index, see **[CLAUDE.md](CLAUDE.md)**. + +## Local Setup + +```bash +python -m venv .venv +python -m pip install -e ".[dev]" +``` + +## Verify + +```bash +python -m ruff check src tests +python -m pytest +python scripts/quickstart_smoke.py +``` + +Use the same commands as **CI** (see **`AGENTS.md`**) before opening a PR. + +## Private files and pushing to GitHub + +Do not commit credentials, customer data, internal strategy docs, or local ledger data. The repo ignores **`.flightdeck/`**, **`.env*`**, optional **`private/`** / **`secrets/`**, and common key/credential patterns—see **`.gitignore`** and **[SECURITY.md](SECURITY.md)**. + +If this clone is your **research repo** (personal `origin`), treat **[docs/research-workflow.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/research-workflow.md)** and **[docs/git-remotes.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/git-remotes.md)** as the source of truth for what may go to org remotes vs what stays local. + +Before the **first push** to an org remote (or any push that should represent “org standards”), follow **[docs/github-organization.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/github-organization.md)**. The **[flightdeckdev](https://github.com/flightdeckdev)** org is for planned repos when you are ready; prefer **one solid default repo** until a real split is needed. + +## Pull Requests + +Keep PRs small and focused. Include tests for behavior changes and docs for user-facing CLI, +schema, or workflow changes. + +## Commit Style + +Use Conventional Commits: + +- `feat(release): add immutable release registry` +- `fix(diff): cost baseline with baseline pricing table` +- `docs(repo): add ADR process` +- `test(policy): cover failed promotion history` + +Useful scopes: `release`, `diff`, `ledger`, `pricing`, `policy`, `storage`, `cli`, `sdk`, +`server`, `schemas`, `docs`, `ci`, `repo`, `security`. + +## Design Changes + +Use an ADR for changes that affect schemas, storage, release semantics, public CLI behavior, or +the local-first architecture. + +## AI-Assisted Contributions + +AI-assisted code is allowed only when the contributor understands it, tests it, and accepts +responsibility for it. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..ddc1089 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,88 @@ +# Development + +## Requirements + +- Python **3.11+** (CI runs **3.11** through **3.14** on Ubuntu and Windows) +- Git + +## Setup + +```bash +python -m venv .venv +python -m pip install -e ".[dev]" +``` + +## Verify + +```bash +python -m ruff check src tests +python -m pytest +flightdeck --help +flightdeck doctor +python scripts/quickstart_smoke.py +``` + +Full command flags and exit codes: [docs/cli.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md). Cross-platform quickstart parity: **`scripts/quickstart_smoke.py`** (also run in CI). + +Before pushing to an **org** remote, follow the maintainer checklist in [docs/github-organization.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/github-organization.md) and [docs/research-workflow.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/research-workflow.md) ([git remotes](https://github.com/flightdeckdev/flightdeck/blob/main/docs/git-remotes.md): `origin` = personal research, `org` = flightdeckdev). + +## Local Demo + +```bash +flightdeck init +flightdeck pricing import examples/quickstart/pricing-baseline.yaml +flightdeck pricing import examples/quickstart/pricing-candidate.yaml +flightdeck policy set examples/quickstart/policy.yaml +BASELINE=$(flightdeck release register examples/quickstart/baseline-release) +CANDIDATE=$(flightdeck release register examples/quickstart/candidate-release) + +sed "s/__BASELINE_RELEASE_ID__/${BASELINE}/g" examples/quickstart/baseline-events.jsonl > baseline-events.jsonl +sed "s/__CANDIDATE_RELEASE_ID__/${CANDIDATE}/g" examples/quickstart/candidate-events.jsonl > candidate-events.jsonl + +flightdeck runs ingest baseline-events.jsonl +flightdeck runs ingest candidate-events.jsonl + +flightdeck release diff "$BASELINE" "$CANDIDATE" --window 7d +flightdeck release promote "$BASELINE" --env local --window 7d --reason "initial baseline" +flightdeck release history --agent agent_support --env local +``` + +For a fully runnable demo that generates matching run events after release registration: + +```bash +./scripts/smoke.sh +``` + +## Local State + +`flightdeck init` creates `flightdeck.yaml`. By default, local SQLite data lives at: + +```text +.flightdeck/flightdeck.db +``` + +## Troubleshooting + +If your OS temp directory is restricted, set `TMPDIR`, `TEMP`, or `TMP` to a repo-local `.tmp` +directory before running tests. + +On some Windows setups, pytest may fail to create or clean its temp directories under the default +`%TEMP%` path. If you see `PermissionError` errors mentioning `pytest-of-...` or `pytest`, point temp +dirs at the repo-local `.tmp/` directory: + +```powershell +$env:TEMP = (Resolve-Path .tmp).Path +$env:TMP = $env:TEMP +$env:TMPDIR = $env:TEMP +.\.venv\Scripts\python.exe -m pytest +``` + +By default, `tests/conftest.py` also redirects `TEMP`/`TMP` into `.tmp/` during pytest on Windows. Set +`FLIGHTDECK_USE_SYSTEM_TEMP=1` if you want to force pytest to use your normal OS temp directory instead. + +If your shell does not activate virtual environments in the same way as the examples, use the +virtual environment's Python executable directly: + +```bash +.venv/bin/python -m pytest +``` diff --git a/LICENSE b/LICENSE index 261eeb9..d645695 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,4 @@ + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..14d1f6a --- /dev/null +++ b/NOTICE @@ -0,0 +1,14 @@ +FlightDeck +Copyright 2026 The FlightDeck Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..34c54d5 --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# FlightDeck + +FlightDeck is **AI Release Governance** for production agents. + +It gives teams a local-first control loop for release safety: register immutable agent +releases, ingest runtime evidence, compare trusted diffs, and gate promotion with policy. + +FlightDeck is not an agent framework, prompt IDE, tracing dashboard, or gateway. It is the +operating record for what changed, what it costs, how it behaves, and whether it is safe to +promote. + +## Why It Exists + +AI agent changes can silently alter cost, latency, failure rate, and unit economics. FlightDeck +turns those changes into explicit release decisions backed by runtime evidence. + +Current local spine: + +- versioned `release.yaml` artifacts with bundle checksums +- `RunEvent` ingestion from JSONL or JSON arrays +- immutable pricing tables with explicit `--replace` +- trusted `flightdeck release diff` +- policy-gated `flightdeck release promote` +- promotion decision history + +## Status + +FlightDeck is **local-first** and ships as a Python CLI backed by SQLite. + +**v1.0.0** establishes **SemVer-stable public contracts** for the documented CLI +(**[docs/cli.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md)** on `main`), +committed **`schemas/v1/`**, and **`POST /v1/events`** with **`api_version` `v1`**. See +**[RELEASE_NOTES.md](RELEASE_NOTES.md)** and +**[docs/spec-v1-forward.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md)**. +The product scope is still intentionally narrow (release governance, not a hosted agent platform). + +Not implemented yet: + +- hosted control plane +- automated traffic routing +- tool-cost pricing +- OpenTelemetry import/export mapping (optional **`pip install 'flightdeck[telemetry]'`** pulls deps for future work) + +Shipped locally: + +- `flightdeck serve` + `POST /v1/events` +- minimal Python SDK (`flightdeck.sdk.client`) +- `flightdeck release rollback` (policy-gated, audited) + +## Quickstart + +```bash +python -m venv .venv +python -m pip install -e ".[dev]" +flightdeck --help +``` + +Run the cross-platform quickstart smoke (same as CI): + +```bash +python scripts/quickstart_smoke.py +``` + +Or use the bash wrapper (Git Bash / WSL on Windows): + +```bash +./scripts/smoke.sh +``` + +Or walk through the core commands: + +```bash +flightdeck init +flightdeck pricing import examples/quickstart/pricing-baseline.yaml +flightdeck pricing import examples/quickstart/pricing-candidate.yaml +flightdeck policy set examples/quickstart/policy.yaml + +BASELINE=$(flightdeck release register examples/quickstart/baseline-release) +CANDIDATE=$(flightdeck release register examples/quickstart/candidate-release) + +sed "s/__BASELINE_RELEASE_ID__/${BASELINE}/g" examples/quickstart/baseline-events.jsonl > baseline-events.jsonl +sed "s/__CANDIDATE_RELEASE_ID__/${CANDIDATE}/g" examples/quickstart/candidate-events.jsonl > candidate-events.jsonl + +flightdeck runs ingest baseline-events.jsonl +flightdeck runs ingest candidate-events.jsonl + +flightdeck release diff "$BASELINE" "$CANDIDATE" --window 7d +flightdeck release promote "$BASELINE" --env local --window 7d --reason "initial baseline" +flightdeck release history --agent agent_support --env local +``` + +The static event files in `examples/quickstart` use placeholder release IDs so the repo can ship stable examples. +Substitute them before ingestion, or run **`python scripts/quickstart_smoke.py`** (any OS) or **`./scripts/smoke.sh`** from Git Bash/WSL on Windows. + +## Documentation + +This tree stays small; narrative docs live on **[github.com/flightdeckdev/flightdeck](https://github.com/flightdeckdev/flightdeck)** (`main`): + +- [Quickstart](https://github.com/flightdeckdev/flightdeck/blob/main/docs/quickstart.md) +- [CLI reference](https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md) +- [Architecture](https://github.com/flightdeckdev/flightdeck/blob/main/docs/architecture.md) +- [Specification (0.x snapshot)](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec.md) +- [Forward spec — v1 GA track](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md) +- [v1 next steps (backlog)](https://github.com/flightdeckdev/flightdeck/blob/main/docs/v1-next-steps.md) +- [JSON Schemas](schemas/v1/) (in this repo) +- [Changelog](CHANGELOG.md) +- [Release notes (maintainer)](RELEASE_NOTES.md) +- [Roadmap](ROADMAP.md) +- [Contributing](CONTRIBUTING.md) +- [CLAUDE.md](CLAUDE.md) (short agent entry; see [AGENTS.md](AGENTS.md) for full rules) +- [Development](DEVELOPMENT.md) +- [Security](SECURITY.md) +- [Research clone vs org repos](https://github.com/flightdeckdev/flightdeck/blob/main/docs/research-workflow.md) +- [Git remotes: personal vs org](https://github.com/flightdeckdev/flightdeck/blob/main/docs/git-remotes.md) +- [GitHub org & push gate](https://github.com/flightdeckdev/flightdeck/blob/main/docs/github-organization.md) + +## Development + +```bash +python -m ruff check src tests +python -m pytest +``` + +See [DEVELOPMENT.md](DEVELOPMENT.md) for setup, verification, and troubleshooting. + +## License + +FlightDeck is licensed under the **Apache License, Version 2.0** — see [`LICENSE`](LICENSE) and [`NOTICE`](NOTICE). + +The canonical public repository: [https://github.com/flightdeckdev/flightdeck](https://github.com/flightdeckdev/flightdeck). diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..0174129 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,49 @@ +# Release notes (maintainer) + +High-level notes for **shipping FlightDeck**. Detailed history: **[CHANGELOG.md](CHANGELOG.md)**. Contract direction: **[docs/spec-v1-forward.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md)**. Backlog and milestone status: **[docs/v1-next-steps.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/v1-next-steps.md)**. + +Narrative docs (including the CLI reference) are maintained on **[github.com/flightdeckdev/flightdeck](https://github.com/flightdeckdev/flightdeck)** `main`; this file and **`schemas/`** ship in minimal clones. + +## v1.0.1 — distribution and developer tooling + +Patch release (see **[CHANGELOG.md](CHANGELOG.md)**): canonical **`main`** URLs for narrative docs in slim clones, optional OpenTelemetry in extras only, CI coverage for **Python 3.13–3.14**, repo-local **pytest** basetemp on Windows, **ruff** pinned consistently with **pre-commit**, **`.gitattributes`** LF for the golden bundle fixture, and removal of a test that depended on unpublished export scripts. **Public CLI / schema / HTTP contracts** are unchanged from **v1.0.0**. + +## v1.0.0 — stable public contracts + +**v1.0.0** freezes the following unless a future **major** release says otherwise: + +- **CLI** — commands, flags, and exit codes as documented in **[docs/cli.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md)** (including **`release verify`** exit **2** on checksum mismatch). +- **JSON Schemas** — committed **`schemas/v1/`** for **`api_version` `v1`** payloads; regenerate via **`python scripts/generate_schemas.py`**; CI guards drift. +- **HTTP** — **`POST /v1/events`**, envelope **`{ "events": [ … ] }`**, per-event **`api_version`** omitted or **`"v1"`**; rejects other values with **HTTP 400** and the stable **`detail`** format covered by tests. +- **SQLite** — forward-only numbered migrations; **`flightdeck doctor`** checks migrations and **`audit_seq`** continuity as shipped. + +The product remains **local-first**; hosted control plane, OTel mapping, and related items stay on **`ROADMAP.md`**. + +Optional OTEL libraries (not used by core today): **`pip install 'flightdeck[telemetry]'`**. + +## Python SDK + +**`flightdeck.sdk`** ships with the same SemVer as the CLI; CI covers **`flightdeck.sdk.client`** for local ingest helpers. For the strictest stability expectations, prefer the **CLI** and **HTTP** contracts above. + +## Trust boundaries (local spine) + +- **`flightdeck serve`**: binding to a non-loopback address prints a warning; **there is no HTTP auth** in the default local server — treat network exposure as operator-controlled risk. +- **`flightdeck release verify`**: compares **registered bundle checksum** vs **current directory tree hash**; exit **2** on mismatch (**1** for normal CLI errors). +- **`flightdeck doctor`**: read-only checks for SQLite migrations through **`LATEST_SCHEMA_MIGRATION_VERSION`** and basic **`promoted_releases` ↔ `releases`** consistency; **`audit_seq`** on **`release_actions`** must be contiguous (**0.6.0+**). + +## SQLite upgrades + +Schema evolves via **numbered migrations**. Existing **`flightdeck.yaml`** / **`.flightdeck/`** trees pick up new migrations on next CLI or **`serve`** startup when **`Storage.migrate()`** runs. If you maintain long-lived local databases, skim **[CHANGELOG.md](CHANGELOG.md)** before upgrading across **minor** versions. + +## Semantic versioning (from v1.0.0) + +**Patch** — bug fixes and internal refactors that preserve CLI/schema/HTTP contracts. + +**Minor** — backward-compatible additions (new optional flags, additive JSON fields within **`api_version` `v1`**, new commands). + +**Major** — breaking CLI contracts, breaking **`v1`** payload shapes, or removal of documented behavior. Migration notes belong in **CHANGELOG** and here when relevant. + +## Payload versioning + +- **`api_version`** on **`RunEvent`** and **`ReleaseArtifact`** is **`"v1"`** for this freeze; **`POST /v1/events`** rejects other values with **HTTP 400** and a stable **`detail`** string (missing key defaults to **`v1`** server-side before validation). +- JSON Schemas under **`schemas/v1/`** are generated — run **`python scripts/generate_schemas.py`** after model changes; CI fails on drift. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..732e03d --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,29 @@ +# Roadmap + +## Now + +- Local release registry +- Run event ingestion from JSONL/JSON arrays +- Trusted release diff +- Immutable pricing tables (with import audit log) +- Policy-gated promotion +- Promotion history +- Local HTTP ingestion (`flightdeck serve`) +- Rollback command (`flightdeck release rollback`) + +## Next (post-1.0 — see **[docs/v1-next-steps.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/v1-next-steps.md)**) + +- **v1.0.0 — shipped:** SemVer-stable CLI + **`schemas/v1/`** + **`POST /v1/events`** **`v1`**; **[RELEASE_NOTES.md](RELEASE_NOTES.md)**; **[docs/spec-v1-forward.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md)**. +- **Prior milestones — shipped:** **0.6** (**`audit_seq`**, **`doctor`**), **0.7** (**`release verify`**, golden bundle, CI schema drift), **0.8** ([docs/cli.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md), **`quickstart_smoke.py`**), **0.9** (HTTP **`api_version`** tests, JSON fixtures, maintainer release notes). +- JSON Schemas: optional expansion of golden fixtures + explicit **schema compatibility** policy text as the surface grows. +- Expand SDK: retries, batching, optional async client ([spec-v1-forward](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md) §6). + +Normative direction: **[docs/spec-v1-forward.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md)** · backlog: **[docs/v1-next-steps.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/v1-next-steps.md)** · **canonical publish / staging** (full maintainer runbook): **[docs/github-organization.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/github-organization.md)** · **[docs/spec.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec.md)** remains the 0.x implementation narrative snapshot. + +## Later + +- Hosted control plane +- Dashboard +- OpenTelemetry import/export mapping +- Tool-cost pricing +- Enterprise controls diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..337fa12 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,35 @@ +# Security + +## Supported Versions + +From **v1.0.0**, security fixes land on **`main`** and should be released as **patch** versions on +the latest **1.x** line when applicable. Report against a specific **version** or **commit**. + +## Reporting A Vulnerability + +Do not open a public issue for suspected vulnerabilities. + +On **[github.com/flightdeckdev/flightdeck](https://github.com/flightdeckdev/flightdeck)**, prefer **GitHub → Security → Report a vulnerability** once **private vulnerability reporting** is enabled for that repository. + +If private reporting is unavailable, contact the maintainer privately through the **[repository owner profile](https://github.com/flightdeckdev)** (organization) or your fork’s owner—do not use public issues for suspected vulnerabilities. + +Please include: + +- affected version or commit +- reproduction steps +- impact +- suggested remediation, if known + +## Secrets + +Do not include credentials, API keys, customer data, traces with sensitive content, or private +company information in issues, discussions, examples, or tests. + +### Repository hygiene + +- **Local ledger:** default SQLite path lives under **`.flightdeck/`**; that directory is **gitignored**. Do not force-add it. +- **Environment:** never commit **`.env`** or **`.env.*`** files. +- **Optional local-only trees:** **`private/`** and **`secrets/`** are gitignored for material that must not ship with the public tree. +- **Keys / certs:** patterns such as **`*.pem`**, **`*.p12`**, and common `*credentials*.json` names are ignored—still review `git status` before every commit. + +See **[docs/github-organization.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/github-organization.md)** for a pre-push checklist aligned with the **[flightdeckdev](https://github.com/flightdeckdev)** org. diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 0000000..5316d9a --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,34 @@ +# Versioning + +FlightDeck uses package versions and schema versions separately. + +## Package Versions + +Package releases use [Semantic Versioning](https://semver.org/). + +From **`v1.0.0`**, documented CLI behavior (**[docs/cli.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md)**), committed **`schemas/v1/`**, and +**`POST /v1/events`** **`api_version` `v1`** payloads are **stable public contracts** except when a +**major** release explicitly documents a break. Breaking changes must appear in +**[CHANGELOG.md](CHANGELOG.md)** and, when user-visible, **[RELEASE_NOTES.md](RELEASE_NOTES.md)**. + +## Schema Versions + +Public payloads include `api_version`. + +- Additive fields can remain on the same `api_version`. +- Renames, removals, or semantic changes require a new schema version. +- Unknown required schema versions should fail with actionable errors. + +## Database Migrations + +Local SQLite uses **explicit numbered migrations** (see `flightdeck.storage` and +`LATEST_SCHEMA_MIGRATION_VERSION`). New schema steps must ship with a migration number, +tests where behavior changes, and **`doctor`** / docs updated when invariants change +(e.g. `audit_seq` in 0.6.0). + +Hosted or multi-user operation remains out of scope for the default local spine; migrations +are still the mechanism for evolving the on-disk schema safely. + +## Stable contracts (v1.0.0) + +The **v1.0.0** freeze is summarized in **[RELEASE_NOTES.md](RELEASE_NOTES.md)** and **[docs/spec-v1-forward.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md)**; milestone status lives in **[docs/v1-next-steps.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/v1-next-steps.md)**. diff --git a/examples/quickstart/README.md b/examples/quickstart/README.md new file mode 100644 index 0000000..30e6b41 --- /dev/null +++ b/examples/quickstart/README.md @@ -0,0 +1,16 @@ +# Quickstart examples + +These files are meant to be copied or substituted locally: + +- `baseline-release/` and `candidate-release/` are example `release.yaml` bundles. +- `pricing-*.yaml` are example pricing tables (immutable by default; use `--replace` to update). +- `policy.yaml` is an example active policy used by `release diff` and `release promote`. +- `*-events.jsonl` contain placeholder `release_id` values (`__BASELINE_RELEASE_ID__`, `__CANDIDATE_RELEASE_ID__`). + +Fastest path: + +- Run `../../scripts/smoke.sh` from a Unix shell (Git Bash/WSL on Windows). + +Manual path: + +- See [docs/quickstart.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/quickstart.md). diff --git a/examples/quickstart/baseline-events.jsonl b/examples/quickstart/baseline-events.jsonl new file mode 100644 index 0000000..1e6d886 --- /dev/null +++ b/examples/quickstart/baseline-events.jsonl @@ -0,0 +1 @@ +{"api_version":"v1","type":"run_end","timestamp":"2026-04-30T20:00:00+00:00","workspace_id":"ws_local","agent_id":"agent_support","release_id":"__BASELINE_RELEASE_ID__","run_id":"baseline-example-1","tenant_id":"tenant_acme","task_id":"task_support","environment":"local","metrics":{"latency_ms":1000,"success":true,"error_type":null},"usage":{"model":{"provider":"openai","model":"gpt-4.1-mini","input_tokens":1000,"output_tokens":500,"cached_input_tokens":0},"tools":[]},"labels":{"example":"quickstart"}} diff --git a/examples/quickstart/baseline-release/prompts/system.md b/examples/quickstart/baseline-release/prompts/system.md new file mode 100644 index 0000000..03c76cf --- /dev/null +++ b/examples/quickstart/baseline-release/prompts/system.md @@ -0,0 +1 @@ +You are a support agent that answers customer questions clearly and briefly. diff --git a/examples/quickstart/baseline-release/release.yaml b/examples/quickstart/baseline-release/release.yaml new file mode 100644 index 0000000..7e2edc9 --- /dev/null +++ b/examples/quickstart/baseline-release/release.yaml @@ -0,0 +1,22 @@ +api_version: v1 +kind: Release +metadata: + name: support-agent + version: baseline + description: Baseline support agent release. +spec: + agent: + agent_id: agent_support + runtime: + provider: openai + model: gpt-4.1-mini + temperature: 0.2 + max_output_tokens: 800 + prompts: + system_ref: prompts/system.md + pricing_reference: + provider: openai + pricing_version: baseline-pricing + tags: + env: local + example: quickstart diff --git a/examples/quickstart/candidate-events.jsonl b/examples/quickstart/candidate-events.jsonl new file mode 100644 index 0000000..0da43b7 --- /dev/null +++ b/examples/quickstart/candidate-events.jsonl @@ -0,0 +1 @@ +{"api_version":"v1","type":"run_end","timestamp":"2026-04-30T20:00:00+00:00","workspace_id":"ws_local","agent_id":"agent_support","release_id":"__CANDIDATE_RELEASE_ID__","run_id":"candidate-example-1","tenant_id":"tenant_acme","task_id":"task_support","environment":"local","metrics":{"latency_ms":1200,"success":true,"error_type":null},"usage":{"model":{"provider":"openai","model":"gpt-4.1-mini","input_tokens":1000,"output_tokens":500,"cached_input_tokens":0},"tools":[]},"labels":{"example":"quickstart"}} diff --git a/examples/quickstart/candidate-release/prompts/system.md b/examples/quickstart/candidate-release/prompts/system.md new file mode 100644 index 0000000..89b8127 --- /dev/null +++ b/examples/quickstart/candidate-release/prompts/system.md @@ -0,0 +1 @@ +You are a support agent that answers customer questions with more detail and citations. diff --git a/examples/quickstart/candidate-release/release.yaml b/examples/quickstart/candidate-release/release.yaml new file mode 100644 index 0000000..0124ca5 --- /dev/null +++ b/examples/quickstart/candidate-release/release.yaml @@ -0,0 +1,22 @@ +api_version: v1 +kind: Release +metadata: + name: support-agent + version: candidate + description: Candidate support agent release with different pricing assumptions. +spec: + agent: + agent_id: agent_support + runtime: + provider: openai + model: gpt-4.1-mini + temperature: 0.2 + max_output_tokens: 800 + prompts: + system_ref: prompts/system.md + pricing_reference: + provider: openai + pricing_version: candidate-pricing + tags: + env: local + example: quickstart diff --git a/examples/quickstart/policy.yaml b/examples/quickstart/policy.yaml new file mode 100644 index 0000000..4b2dcb5 --- /dev/null +++ b/examples/quickstart/policy.yaml @@ -0,0 +1,8 @@ +policy_id: quickstart-policy +max_cost_per_run_usd: 4.0 +max_latency_ms: 3000 +max_error_rate: 0.05 +min_candidate_runs: 1 +min_baseline_runs: 1 +min_low_runs: 1 +require_high_diff_confidence: false diff --git a/examples/quickstart/pricing-baseline.yaml b/examples/quickstart/pricing-baseline.yaml new file mode 100644 index 0000000..b4be25d --- /dev/null +++ b/examples/quickstart/pricing-baseline.yaml @@ -0,0 +1,6 @@ +provider: openai +pricing_version: baseline-pricing +entries: + - model: gpt-4.1-mini + input_usd_per_1k_tokens: 1.0 + output_usd_per_1k_tokens: 2.0 diff --git a/examples/quickstart/pricing-candidate.yaml b/examples/quickstart/pricing-candidate.yaml new file mode 100644 index 0000000..ad36ac3 --- /dev/null +++ b/examples/quickstart/pricing-candidate.yaml @@ -0,0 +1,6 @@ +provider: openai +pricing_version: candidate-pricing +entries: + - model: gpt-4.1-mini + input_usd_per_1k_tokens: 3.0 + output_usd_per_1k_tokens: 4.0 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..46d6b68 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,76 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "flightdeck" +version = "1.0.1" +description = "AI Release Governance for production agents." +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.11" +authors = [{ name = "FlightDeck" }] +keywords = ["ai", "agents", "control-plane", "finops", "deployment", "policy", "operations"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Topic :: System :: Monitoring", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [ + "pydantic>=2.0", + "click>=8.0", + "fastapi>=0.100.0", + "uvicorn[standard]>=0.20.0", + "sqlalchemy>=2.0", + "aiosqlite>=0.19.0", + "httpx>=0.24.0", + "pyyaml>=6.0", + "rich>=13.0", +] + +[project.optional-dependencies] +openai = ["openai>=1.0"] +anthropic = ["anthropic>=0.20"] +telemetry = [ + "opentelemetry-api>=1.20.0", + "opentelemetry-sdk>=1.20.0", + "opentelemetry-exporter-otlp>=1.20.0", +] +all = [ + "openai>=1.0", + "anthropic>=0.20", + "opentelemetry-api>=1.20.0", + "opentelemetry-sdk>=1.20.0", + "opentelemetry-exporter-otlp>=1.20.0", +] +dev = [ + "pytest>=7.0", + "ruff==0.15.12", +] + +[project.urls] +Homepage = "https://github.com/flightdeckdev/flightdeck" +Repository = "https://github.com/flightdeckdev/flightdeck" +Issues = "https://github.com/flightdeckdev/flightdeck/issues" +Changelog = "https://github.com/flightdeckdev/flightdeck/blob/main/CHANGELOG.md" + +[project.scripts] +flightdeck = "flightdeck.cli.main:cli" + +[tool.hatch.build.targets.wheel] +packages = ["src/flightdeck"] + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.pytest.ini_options] +testpaths = ["tests"] +# Keep pytest basetemp under the repo (gitignored `.tmp/`) so Windows runs do not rely +# on `%TEMP%\pytest-of-*`, which can raise PermissionError when scandir is denied. +addopts = "--basetemp=.tmp/pytest" diff --git a/schemas/v1/policy.schema.json b/schemas/v1/policy.schema.json new file mode 100644 index 0000000..7a1465e --- /dev/null +++ b/schemas/v1/policy.schema.json @@ -0,0 +1,95 @@ +{ + "properties": { + "max_cost_per_run_usd": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Cost Per Run Usd" + }, + "max_error_rate": { + "anyOf": [ + { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Error Rate" + }, + "max_latency_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Latency Ms" + }, + "min_baseline_runs": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Min Baseline Runs" + }, + "min_candidate_runs": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Min Candidate Runs" + }, + "min_low_runs": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Min Low Runs" + }, + "policy_id": { + "default": "default", + "title": "Policy Id", + "type": "string" + }, + "require_high_diff_confidence": { + "default": true, + "title": "Require High Diff Confidence", + "type": "boolean" + } + }, + "title": "Policy", + "type": "object" +} diff --git a/schemas/v1/pricing_table.schema.json b/schemas/v1/pricing_table.schema.json new file mode 100644 index 0000000..ab7b820 --- /dev/null +++ b/schemas/v1/pricing_table.schema.json @@ -0,0 +1,66 @@ +{ + "$defs": { + "PricingEntry": { + "properties": { + "cached_input_usd_per_1k_tokens": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cached Input Usd Per 1K Tokens" + }, + "input_usd_per_1k_tokens": { + "minimum": 0, + "title": "Input Usd Per 1K Tokens", + "type": "number" + }, + "model": { + "title": "Model", + "type": "string" + }, + "output_usd_per_1k_tokens": { + "minimum": 0, + "title": "Output Usd Per 1K Tokens", + "type": "number" + } + }, + "required": [ + "model", + "input_usd_per_1k_tokens", + "output_usd_per_1k_tokens" + ], + "title": "PricingEntry", + "type": "object" + } + }, + "properties": { + "entries": { + "items": { + "$ref": "#/$defs/PricingEntry" + }, + "title": "Entries", + "type": "array" + }, + "pricing_version": { + "title": "Pricing Version", + "type": "string" + }, + "provider": { + "title": "Provider", + "type": "string" + } + }, + "required": [ + "provider", + "pricing_version", + "entries" + ], + "title": "PricingTable", + "type": "object" +} diff --git a/schemas/v1/release.schema.json b/schemas/v1/release.schema.json new file mode 100644 index 0000000..979c5fe --- /dev/null +++ b/schemas/v1/release.schema.json @@ -0,0 +1,396 @@ +{ + "$defs": { + "ReleaseMetadata": { + "properties": { + "created_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Created At" + }, + "created_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Created By" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "name": { + "title": "Name", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "title": "ReleaseMetadata", + "type": "object" + }, + "ReleasePricingReference": { + "properties": { + "pricing_version": { + "title": "Pricing Version", + "type": "string" + }, + "provider": { + "title": "Provider", + "type": "string" + } + }, + "required": [ + "provider", + "pricing_version" + ], + "title": "ReleasePricingReference", + "type": "object" + }, + "ReleaseSpec": { + "properties": { + "agent": { + "$ref": "#/$defs/ReleaseSpecAgent" + }, + "pricing_reference": { + "$ref": "#/$defs/ReleasePricingReference" + }, + "prompts": { + "$ref": "#/$defs/ReleaseSpecPrompts" + }, + "routing": { + "anyOf": [ + { + "$ref": "#/$defs/ReleaseSpecRouting" + }, + { + "type": "null" + } + ], + "default": null + }, + "runtime": { + "$ref": "#/$defs/ReleaseSpecRuntime" + }, + "safety": { + "anyOf": [ + { + "$ref": "#/$defs/ReleaseSpecSafety" + }, + { + "type": "null" + } + ], + "default": null + }, + "tags": { + "additionalProperties": { + "type": "string" + }, + "title": "Tags", + "type": "object" + }, + "tools": { + "anyOf": [ + { + "$ref": "#/$defs/ReleaseSpecTools" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "agent", + "runtime", + "prompts", + "pricing_reference" + ], + "title": "ReleaseSpec", + "type": "object" + }, + "ReleaseSpecAgent": { + "properties": { + "agent_id": { + "title": "Agent Id", + "type": "string" + }, + "entrypoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Entrypoint" + } + }, + "required": [ + "agent_id" + ], + "title": "ReleaseSpecAgent", + "type": "object" + }, + "ReleaseSpecPrompts": { + "properties": { + "system_ref": { + "title": "System Ref", + "type": "string" + }, + "template_refs": { + "items": { + "type": "string" + }, + "title": "Template Refs", + "type": "array" + } + }, + "required": [ + "system_ref" + ], + "title": "ReleaseSpecPrompts", + "type": "object" + }, + "ReleaseSpecRouting": { + "properties": { + "fallback": { + "anyOf": [ + { + "$ref": "#/$defs/ReleaseSpecRoutingFallback" + }, + { + "type": "null" + } + ], + "default": null + }, + "strategy": { + "default": "single_model", + "enum": [ + "single_model", + "fallback_model" + ], + "title": "Strategy", + "type": "string" + } + }, + "title": "ReleaseSpecRouting", + "type": "object" + }, + "ReleaseSpecRoutingFallback": { + "properties": { + "model": { + "title": "Model", + "type": "string" + }, + "on_error": { + "default": true, + "title": "On Error", + "type": "boolean" + } + }, + "required": [ + "model" + ], + "title": "ReleaseSpecRoutingFallback", + "type": "object" + }, + "ReleaseSpecRuntime": { + "properties": { + "max_output_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Output Tokens" + }, + "model": { + "title": "Model", + "type": "string" + }, + "provider": { + "title": "Provider", + "type": "string" + }, + "temperature": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Temperature" + } + }, + "required": [ + "provider", + "model" + ], + "title": "ReleaseSpecRuntime", + "type": "object" + }, + "ReleaseSpecSafety": { + "properties": { + "retry_policy": { + "$ref": "#/$defs/ReleaseSpecSafetyRetryPolicy" + }, + "timeouts_ms": { + "anyOf": [ + { + "$ref": "#/$defs/ReleaseSpecSafetyTimeouts" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "ReleaseSpecSafety", + "type": "object" + }, + "ReleaseSpecSafetyRetryPolicy": { + "properties": { + "backoff_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Backoff Ms" + }, + "max_retries": { + "default": 0, + "title": "Max Retries", + "type": "integer" + } + }, + "title": "ReleaseSpecSafetyRetryPolicy", + "type": "object" + }, + "ReleaseSpecSafetyTimeouts": { + "properties": { + "model_call": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model Call" + }, + "tool_call": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Call" + } + }, + "title": "ReleaseSpecSafetyTimeouts", + "type": "object" + }, + "ReleaseSpecTools": { + "properties": { + "manifest_ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Manifest Ref" + }, + "tool_names": { + "items": { + "type": "string" + }, + "title": "Tool Names", + "type": "array" + } + }, + "title": "ReleaseSpecTools", + "type": "object" + } + }, + "properties": { + "api_version": { + "const": "v1", + "default": "v1", + "title": "Api Version", + "type": "string" + }, + "kind": { + "const": "Release", + "default": "Release", + "title": "Kind", + "type": "string" + }, + "metadata": { + "$ref": "#/$defs/ReleaseMetadata" + }, + "spec": { + "$ref": "#/$defs/ReleaseSpec" + } + }, + "required": [ + "metadata", + "spec" + ], + "title": "ReleaseArtifact", + "type": "object" +} diff --git a/schemas/v1/run_event.schema.json b/schemas/v1/run_event.schema.json new file mode 100644 index 0000000..74f662e --- /dev/null +++ b/schemas/v1/run_event.schema.json @@ -0,0 +1,249 @@ +{ + "$defs": { + "RunEventMetrics": { + "properties": { + "error_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error Type" + }, + "latency_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Latency Ms" + }, + "success": { + "default": true, + "title": "Success", + "type": "boolean" + } + }, + "title": "RunEventMetrics", + "type": "object" + }, + "RunEventModelUsage": { + "properties": { + "cached_input_tokens": { + "default": 0, + "minimum": 0, + "title": "Cached Input Tokens", + "type": "integer" + }, + "input_tokens": { + "minimum": 0, + "title": "Input Tokens", + "type": "integer" + }, + "model": { + "title": "Model", + "type": "string" + }, + "output_tokens": { + "minimum": 0, + "title": "Output Tokens", + "type": "integer" + }, + "provider": { + "title": "Provider", + "type": "string" + } + }, + "required": [ + "provider", + "model", + "input_tokens", + "output_tokens" + ], + "title": "RunEventModelUsage", + "type": "object" + }, + "RunEventRequest": { + "properties": { + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "span_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Span Id" + }, + "trace_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Trace Id" + } + }, + "title": "RunEventRequest", + "type": "object" + }, + "RunEventToolUsage": { + "properties": { + "cost_units": { + "default": 0.0, + "minimum": 0, + "title": "Cost Units", + "type": "number" + }, + "invocations": { + "default": 0, + "minimum": 0, + "title": "Invocations", + "type": "integer" + }, + "tool_name": { + "title": "Tool Name", + "type": "string" + } + }, + "required": [ + "tool_name" + ], + "title": "RunEventToolUsage", + "type": "object" + }, + "RunEventUsage": { + "properties": { + "model": { + "$ref": "#/$defs/RunEventModelUsage" + }, + "tools": { + "items": { + "$ref": "#/$defs/RunEventToolUsage" + }, + "title": "Tools", + "type": "array" + } + }, + "required": [ + "model" + ], + "title": "RunEventUsage", + "type": "object" + } + }, + "properties": { + "agent_id": { + "title": "Agent Id", + "type": "string" + }, + "api_version": { + "const": "v1", + "default": "v1", + "title": "Api Version", + "type": "string" + }, + "environment": { + "title": "Environment", + "type": "string" + }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "title": "Labels", + "type": "object" + }, + "metrics": { + "$ref": "#/$defs/RunEventMetrics" + }, + "release_id": { + "title": "Release Id", + "type": "string" + }, + "request": { + "anyOf": [ + { + "$ref": "#/$defs/RunEventRequest" + }, + { + "type": "null" + } + ], + "default": null + }, + "run_id": { + "title": "Run Id", + "type": "string" + }, + "task_id": { + "title": "Task Id", + "type": "string" + }, + "tenant_id": { + "title": "Tenant Id", + "type": "string" + }, + "timestamp": { + "format": "date-time", + "title": "Timestamp", + "type": "string" + }, + "type": { + "default": "run_end", + "enum": [ + "run_start", + "run_end" + ], + "title": "Type", + "type": "string" + }, + "usage": { + "$ref": "#/$defs/RunEventUsage" + }, + "workspace_id": { + "default": "ws_local", + "title": "Workspace Id", + "type": "string" + } + }, + "required": [ + "timestamp", + "agent_id", + "release_id", + "run_id", + "tenant_id", + "task_id", + "environment", + "usage" + ], + "title": "RunEvent", + "type": "object" +} diff --git a/scripts/generate_schemas.py b/scripts/generate_schemas.py new file mode 100644 index 0000000..6dc2a92 --- /dev/null +++ b/scripts/generate_schemas.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from flightdeck.models import Policy, PricingTable, ReleaseArtifact, RunEvent + + +def write_schema(path: Path, schema: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(schema, indent=2, sort_keys=True) + "\n", encoding="utf-8", newline="\n") + + +def main() -> None: + root = Path(__file__).resolve().parents[1] / "schemas" / "v1" + write_schema(root / "release.schema.json", ReleaseArtifact.model_json_schema()) + write_schema(root / "run_event.schema.json", RunEvent.model_json_schema()) + write_schema(root / "pricing_table.schema.json", PricingTable.model_json_schema()) + write_schema(root / "policy.schema.json", Policy.model_json_schema()) + + +if __name__ == "__main__": + main() diff --git a/scripts/quickstart_smoke.py b/scripts/quickstart_smoke.py new file mode 100644 index 0000000..a8b9fc7 --- /dev/null +++ b/scripts/quickstart_smoke.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Cross-platform quickstart smoke (no bash): mirrors examples/quickstart + canonical quickstart flow.""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +QS = REPO / "examples" / "quickstart" +BASELINE_PH = "__BASELINE_RELEASE_ID__" +CANDIDATE_PH = "__CANDIDATE_RELEASE_ID__" + + +def _flightdeck_cmd() -> list[str]: + exe = shutil.which("flightdeck") + if exe: + return [exe] + return [sys.executable, "-m", "flightdeck.cli.main"] + + +def _run(fd: list[str], *args: str, cwd: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [*fd, *args], + cwd=cwd, + check=True, + text=True, + capture_output=True, + ) + + +def main() -> None: + fd = _flightdeck_cmd() + with tempfile.TemporaryDirectory(prefix="fd_qs_", ignore_cleanup_errors=True) as tmp_s: + tmp = Path(tmp_s) + baseline_events = (tmp / "baseline-events.jsonl") + candidate_events = (tmp / "candidate-events.jsonl") + + _run(fd, "init", cwd=tmp) + _run(fd, "pricing", "import", str(QS / "pricing-baseline.yaml"), cwd=tmp) + _run(fd, "pricing", "import", str(QS / "pricing-candidate.yaml"), cwd=tmp) + _run(fd, "policy", "set", str(QS / "policy.yaml"), cwd=tmp) + + reg_b = _run(fd, "release", "register", str(QS / "baseline-release"), cwd=tmp) + baseline_id = reg_b.stdout.strip() + reg_c = _run(fd, "release", "register", str(QS / "candidate-release"), cwd=tmp) + candidate_id = reg_c.stdout.strip() + + baseline_events.write_text( + (QS / "baseline-events.jsonl").read_text(encoding="utf-8").replace(BASELINE_PH, baseline_id), + encoding="utf-8", + ) + candidate_events.write_text( + (QS / "candidate-events.jsonl").read_text(encoding="utf-8").replace(CANDIDATE_PH, candidate_id), + encoding="utf-8", + ) + + _run(fd, "runs", "ingest", str(baseline_events), cwd=tmp) + _run(fd, "runs", "ingest", str(candidate_events), cwd=tmp) + _run(fd, "release", "diff", baseline_id, candidate_id, "--window", "7d", cwd=tmp) + _run( + fd, + "release", + "promote", + baseline_id, + "--env", + "local", + "--window", + "7d", + "--reason", + "quickstart smoke", + cwd=tmp, + ) + _run(fd, "release", "history", "--agent", "agent_support", "--env", "local", cwd=tmp) + _run(fd, "release", "verify", baseline_id, "--path", str(QS / "baseline-release"), cwd=tmp) + _run(fd, "doctor", cwd=tmp) + + print("quickstart_smoke: OK") + + +if __name__ == "__main__": + try: + main() + except subprocess.CalledProcessError as e: + print(e.stderr or e.stdout or str(e), file=sys.stderr) + sys.exit(e.returncode or 1) diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100644 index 0000000..29e635f --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKDIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORKDIR" +} +trap cleanup EXIT + +cd "$WORKDIR" + +flightdeck init +flightdeck pricing import "$ROOT/examples/quickstart/pricing-baseline.yaml" +flightdeck pricing import "$ROOT/examples/quickstart/pricing-candidate.yaml" +flightdeck policy set "$ROOT/examples/quickstart/policy.yaml" + +BASELINE="$(flightdeck release register "$ROOT/examples/quickstart/baseline-release")" +CANDIDATE="$(flightdeck release register "$ROOT/examples/quickstart/candidate-release")" + +cat > baseline-events.jsonl < candidate-events.jsonl < bool: + try: + rel = path.relative_to(base) + except ValueError: + return True + return any(part in _SKIP_DIR_PARTS for part in rel.parts) + + +def iter_bundle_files(path: Path) -> list[Path]: + """List files contributing to the bundle hash (sorted, stable order).""" + if path.is_file(): + return [path] + files: list[Path] = [] + base = path + for p in path.rglob("*"): + if not p.is_file(): + continue + if p.is_symlink(): + continue + if _skip_path(p, base): + continue + files.append(p) + + def sort_key(p: Path) -> str: + return p.relative_to(base).as_posix() + + return sorted(files, key=sort_key) + + +def _content_bytes_for_hash(path: Path) -> bytes: + raw = path.read_bytes() + suffix = path.suffix.lower() + if suffix not in _TEXT_SUFFIXES: + return raw + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + return raw + normalized = text.replace("\r\n", "\n").replace("\r", "\n") + return normalized.encode("utf-8") + + +def bundle_checksum(path: Path) -> str: + """SHA-256 over canonical bundle representation (sorted paths + normalized text).""" + files = iter_bundle_files(path) + h = hashlib.sha256() + base = path if path.is_dir() else path.parent + for f in files: + rel = f.relative_to(base).as_posix() + h.update(rel.encode("utf-8")) + h.update(b"\0") + h.update(_content_bytes_for_hash(f)) + h.update(b"\0") + return h.hexdigest() diff --git a/src/flightdeck/cli/__init__.py b/src/flightdeck/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/flightdeck/cli/main.py b/src/flightdeck/cli/main.py new file mode 100644 index 0000000..7dfb301 --- /dev/null +++ b/src/flightdeck/cli/main.py @@ -0,0 +1,718 @@ +"""FlightDeck CLI - AI Release Governance.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from uuid import uuid4 + +import click +import yaml +from click.exceptions import Exit as ClickExit + +from flightdeck import __version__ +from flightdeck.bundle import bundle_checksum +from flightdeck.config import DEFAULT_CONFIG_FILENAME, load_config, write_default_config +from flightdeck.doctor import run_doctor +from flightdeck.ledger import diff_releases, parse_window +from flightdeck.models import Policy, PolicyResult, PricingTable, PromotionRecord, ReleaseArtifact, ReleaseRecord, RunEvent +from flightdeck.storage import Storage, utc_now + + +def read_release_artifact(path: Path) -> ReleaseArtifact: + content = path.read_text(encoding="utf-8") + data = yaml.safe_load(content) + return ReleaseArtifact.model_validate(data) + + +def default_policy() -> Policy: + # When no policy file is set, match Policy model defaults (strict diff confidence for v1 track). + # Demos and tests set an explicit policy YAML (e.g. require_high_diff_confidence: false) where needed. + return Policy( + max_cost_per_run_usd=None, + max_latency_ms=None, + max_error_rate=None, + ) + + +def actor_name() -> str: + return os.environ.get("USER") or os.environ.get("USERNAME") or "unknown" + + +def parse_events_file(path: Path) -> list[RunEvent]: + events: list[RunEvent] = [] + text = path.read_text(encoding="utf-8").strip() + if not text: + return [] + if text.lstrip().startswith("["): + data = json.loads(text) + for item in data: + events.append(RunEvent.model_validate(item)) + return events + + for line in text.splitlines(): + if not line.strip(): + continue + events.append(RunEvent.model_validate_json(line)) + return events + + +@click.group() +@click.version_option(version=__version__, prog_name="flightdeck") +def cli() -> None: + """FlightDeck - AI Release Governance (release safety ledger + trustworthy diffs).""" + + +@cli.command() +@click.option("--path", "path_", default=DEFAULT_CONFIG_FILENAME, show_default=True) +def init(path_: str) -> None: + """Create a local `flightdeck.yaml` workspace config.""" + p = Path(path_) + if p.exists(): + raise click.ClickException(f"{p} already exists") + written = write_default_config(p) + click.echo(f"Wrote {written}") + + +@cli.command("doctor") +def doctor_cmd() -> None: + """Run read-only health checks on the local ledger (migrations, promoted pointers).""" + cfg = load_config() + storage = Storage(cfg.db_path) + checks = run_doctor(storage) + failed = False + for c in checks: + prefix = "ok " if c.ok else "FAIL" + line = f"{prefix} {c.name}: {c.detail}" + if c.ok: + click.echo(line) + else: + click.echo(click.style(line, fg="red"), err=True) + failed = True + if failed: + raise click.ClickException("Doctor found one or more problems.") + click.echo(f"Doctor: {len(checks)} check(s), all passed.") + + +@cli.command() +@click.option("--host", default="127.0.0.1", show_default=True) +@click.option("--port", default=8765, show_default=True) +@click.option("--reload", is_flag=True, default=False) +def serve(host: str, port: int, reload: bool) -> None: + """Start the local FlightDeck HTTP service (event ingestion).""" + import uvicorn + + loopback_hosts = {"127.0.0.1", "::1", "localhost"} + if host.strip() not in loopback_hosts: + click.echo( + f"Warning: binding to {host!r} exposes HTTP ingest without authentication; " + "use only on trusted networks (see https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md §4).", + err=True, + ) + + uvicorn.run( + "flightdeck.server.app:create_app", + factory=True, + host=host, + port=port, + reload=reload, + ) + + +@cli.group() +def release() -> None: + """Work with Release artifacts.""" + + +@release.command("register") +@click.argument("path", type=click.Path(exists=True, path_type=Path)) +@click.option("--env", "environment", default=None, help="Override environment for this registration.") +def release_register(path: Path, environment: str | None) -> None: + """Register an immutable Release artifact (file or bundle directory).""" + cfg = load_config() + storage = Storage(cfg.db_path) + storage.migrate() + + env = environment or cfg.default_environment + + release_yaml = path / "release.yaml" if path.is_dir() else path + if not release_yaml.exists(): + raise click.ClickException(f"Release artifact not found: {release_yaml}") + + artifact = read_release_artifact(release_yaml) + checksum = bundle_checksum(path) + release_id = f"rel_{uuid4().hex[:12]}" + + record = ReleaseRecord( + release_id=release_id, + agent_id=artifact.spec.agent.agent_id, + version=artifact.metadata.version, + environment=env, + checksum=checksum, + artifact_json=artifact.model_dump(mode="json"), + created_at=utc_now(), + ) + storage.insert_release(record) + click.echo(release_id) + + +@release.command("list") +def release_list() -> None: + """List registered releases.""" + cfg = load_config() + storage = Storage(cfg.db_path) + storage.migrate() + for r in storage.list_releases(): + click.echo(f"{r.release_id}\t{r.agent_id}\t{r.version}\t{r.environment}\t{r.created_at.isoformat()}") + + +@release.command("show") +@click.argument("release_id") +def release_show(release_id: str) -> None: + """Show a registered release record as JSON.""" + cfg = load_config() + storage = Storage(cfg.db_path) + storage.migrate() + + record = storage.get_release(release_id) + if not record: + raise click.ClickException(f"Unknown release: {release_id}") + + click.echo(record.model_dump_json(indent=2)) + + +@release.command("verify") +@click.argument("release_id") +@click.option( + "--path", + "artifact_path", + type=click.Path(exists=True, path_type=Path), + required=True, + help="Bundle directory (or single release.yaml) on disk to hash and compare to the registered checksum.", +) +def release_verify(release_id: str, artifact_path: Path) -> None: + """Verify on-disk artifact bytes match the checksum stored at registration (exit 2 on mismatch).""" + cfg = load_config() + storage = Storage(cfg.db_path) + storage.migrate() + + record = storage.get_release(release_id) + if not record: + raise click.ClickException(f"Unknown release: {release_id}") + + stored = record.checksum + actual = bundle_checksum(artifact_path) + if stored == actual: + click.echo(f"OK: checksum matches for {release_id}") + click.echo(f" sha256={stored}") + return + + click.echo( + f"CHECKSUM MISMATCH for {release_id}\n" + f" stored (DB): {stored}\n" + f" recomputed (disk): {actual}\n" + "Disk content differs from registration (files, line endings, or hashing rules).", + err=True, + ) + raise ClickExit(2) + + +@cli.group() +def pricing() -> None: + """Import and view pricing tables.""" + + +@pricing.command("import") +@click.argument("path", type=click.Path(exists=True, path_type=Path)) +@click.option("--replace", is_flag=True, default=False, help="Replace an existing pricing version (audit-sensitive).") +@click.option( + "--reason", + default=None, + help="Required when using --replace (stored in the pricing import audit log).", +) +def pricing_import(path: Path, replace: bool, reason: str | None) -> None: + """Import a pricing table YAML.""" + cfg = load_config() + storage = Storage(cfg.db_path) + storage.migrate() + + data = yaml.safe_load(path.read_text(encoding="utf-8")) + table = PricingTable.model_validate(data) + try: + storage.insert_pricing_table(table, replace=replace, actor=actor_name(), reason=reason) + except ValueError as e: + raise click.ClickException(str(e)) from e + + verb = "Replaced" if replace else "Imported" + click.echo(f"{verb} {table.provider}/{table.pricing_version}") + + +@pricing.command("show") +@click.option("--provider", required=True) +@click.option("--version", "pricing_version", required=True) +def pricing_show(provider: str, pricing_version: str) -> None: + """Show a pricing table by provider/version.""" + cfg = load_config() + storage = Storage(cfg.db_path) + storage.migrate() + + table = storage.get_pricing_table(provider, pricing_version) + if not table: + raise click.ClickException(f"Pricing table not found: {provider}/{pricing_version}") + click.echo(table.model_dump_json(indent=2)) + + +@cli.group() +def policy() -> None: + """Set and view promotion policy.""" + + +@policy.command("set") +@click.argument("path", type=click.Path(exists=True, path_type=Path)) +def policy_set(path: Path) -> None: + """Set the active promotion policy from YAML.""" + cfg = load_config() + storage = Storage(cfg.db_path) + storage.migrate() + + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + p = Policy.model_validate(data) + storage.set_active_policy(p) + click.echo(f"Set policy {p.policy_id}") + + +@policy.command("show") +def policy_show() -> None: + """Show the active promotion policy.""" + cfg = load_config() + storage = Storage(cfg.db_path) + storage.migrate() + + p = storage.get_active_policy() or default_policy() + click.echo(p.model_dump_json(indent=2)) + + +@cli.group() +def runs() -> None: + """Ingest run events.""" + + +@runs.command("ingest") +@click.argument("path", type=click.Path(exists=True, path_type=Path)) +def runs_ingest(path: Path) -> None: + """Ingest RunEvent JSONL (or JSON array) into local storage.""" + cfg = load_config() + storage = Storage(cfg.db_path) + storage.migrate() + + events = parse_events_file(path) + inserted = storage.insert_run_events(events) + click.echo(f"Inserted {inserted} events") + + +@release.command("diff") +@click.argument("baseline_release_id") +@click.argument("candidate_release_id") +@click.option("--window", required=True, help="Required. Time window like 7d, 24h, 30m.") +@click.option("--tenant", "tenant_id", default=None) +@click.option("--task", "task_id", default=None) +@click.option("--env", "environment", default=None) +def release_diff( + baseline_release_id: str, + candidate_release_id: str, + window: str, + tenant_id: str | None, + task_id: str | None, + environment: str | None, +) -> None: + """Compare two releases over a time window and print a confidence-labeled safety diff.""" + cfg = load_config() + storage = Storage(cfg.db_path) + storage.migrate() + + base = storage.get_release(baseline_release_id) + cand = storage.get_release(candidate_release_id) + if not base: + raise click.ClickException(f"Unknown baseline release: {baseline_release_id}") + if not cand: + raise click.ClickException(f"Unknown candidate release: {candidate_release_id}") + + env = environment or cfg.default_environment + + # Load artifacts and enforce same agent_id unless an explicit escape hatch exists later. + base_artifact = ReleaseArtifact.model_validate(base.artifact_json) + cand_artifact = ReleaseArtifact.model_validate(cand.artifact_json) + if base_artifact.spec.agent.agent_id != cand_artifact.spec.agent.agent_id: + raise click.ClickException( + "Cross-agent diff is not allowed. " + f"Baseline agent_id={base_artifact.spec.agent.agent_id}, " + f"candidate agent_id={cand_artifact.spec.agent.agent_id}." + ) + + base_pricing_ref = base_artifact.spec.pricing_reference + cand_pricing_ref = cand_artifact.spec.pricing_reference + + base_table = storage.get_pricing_table(base_pricing_ref.provider, base_pricing_ref.pricing_version) + if not base_table: + raise click.ClickException( + f"Missing pricing table for baseline {base_pricing_ref.provider}/{base_pricing_ref.pricing_version}. " + f"Run `flightdeck pricing import ...`." + ) + + cand_table = storage.get_pricing_table(cand_pricing_ref.provider, cand_pricing_ref.pricing_version) + if not cand_table: + raise click.ClickException( + f"Missing pricing table for candidate {cand_pricing_ref.provider}/{cand_pricing_ref.pricing_version}. " + f"Run `flightdeck pricing import ...`." + ) + + try: + delta = parse_window(window) + except ValueError as e: + raise click.ClickException(str(e)) from e + until = utc_now() + since = until - delta + + baseline_events = storage.query_runs( + baseline_release_id, + since, + until, + tenant_id=tenant_id, + task_id=task_id, + environment=env, + ) + candidate_events = storage.query_runs( + candidate_release_id, + since, + until, + tenant_id=tenant_id, + task_id=task_id, + environment=env, + ) + + policy = storage.get_active_policy() or default_policy() + try: + result = diff_releases( + cfg=cfg, + policy=policy, + baseline_events=baseline_events, + candidate_events=candidate_events, + baseline_pricing_table=base_table, + candidate_pricing_table=cand_table, + window=window, + ) + except KeyError as e: + # Make missing-model pricing failures explicit and actionable. + raise click.ClickException( + f"Pricing table missing model entry. " + f"baseline_model={base_artifact.spec.runtime.model} " + f"candidate_model={cand_artifact.spec.runtime.model}. " + f"Check pricing tables: " + f"{base_pricing_ref.provider}/{base_pricing_ref.pricing_version} and " + f"{cand_pricing_ref.provider}/{cand_pricing_ref.pricing_version}." + ) from e + except ValueError as e: + raise click.ClickException(str(e)) from e + + click.echo(f"Window: {window} ({since.isoformat()} .. {until.isoformat()})") + click.echo(f"Filters: env={env} tenant={tenant_id or '*'} task={task_id or '*'}") + click.echo( + f"Baseline pricing: {base_pricing_ref.provider}/{base_pricing_ref.pricing_version} " + f"(model={base_artifact.spec.runtime.model})" + ) + click.echo( + f"Candidate pricing: {cand_pricing_ref.provider}/{cand_pricing_ref.pricing_version} " + f"(model={cand_artifact.spec.runtime.model})" + ) + if ( + base_pricing_ref.provider != cand_pricing_ref.provider + or base_pricing_ref.pricing_version != cand_pricing_ref.pricing_version + or base_artifact.spec.runtime.model != cand_artifact.spec.runtime.model + ): + click.echo( + "NOTE: cost delta includes pricing/model assumption changes (pricing reference and/or model differ)." + ) + click.echo(f"Samples: baseline={result.baseline_runs} candidate={result.candidate_runs}") + click.echo(f"Confidence: {result.confidence}" + (f" ({result.confidence_reason})" if result.confidence_reason else "")) + click.echo("") + click.echo( + f"Estimated model token cost/run (USD): {result.baseline.cost_per_run_usd:.6f} -> {result.candidate.cost_per_run_usd:.6f} " + f"(delta {result.delta_cost_per_run_usd:+.6f}" + + (f", {result.delta_cost_per_run_pct:+.2%})" if result.delta_cost_per_run_pct is not None else ")") + ) + if result.baseline.latency_ms_avg is not None and result.candidate.latency_ms_avg is not None: + click.echo( + f"Latency avg (ms): {result.baseline.latency_ms_avg:.2f} -> {result.candidate.latency_ms_avg:.2f} " + f"(delta {result.delta_latency_ms_avg:+.2f})" + ) + click.echo( + f"Error rate: {result.baseline.error_rate:.4f} -> {result.candidate.error_rate:.4f} " + f"(delta {result.delta_error_rate:+.4f})" + ) + click.echo("") + click.echo("Policy: " + ("PASS" if result.policy.passed else "FAIL")) + for r in result.policy.reasons: + click.echo(f"- {r}") + + +@release.command("promote") +@click.argument("release_id") +@click.option("--env", "environment", required=True) +@click.option("--window", required=True, help="Required. Time window like 7d, 24h, 30m.") +@click.option("--reason", required=True, help="Required rationale for the promotion decision.") +def release_promote(release_id: str, environment: str, window: str, reason: str) -> None: + """Promote a release after evaluating active policy against the current baseline.""" + cfg = load_config() + storage = Storage(cfg.db_path) + storage.migrate() + + target = storage.get_release(release_id) + if not target: + raise click.ClickException(f"Unknown release: {release_id}") + + target_artifact = ReleaseArtifact.model_validate(target.artifact_json) + agent_id = target_artifact.spec.agent.agent_id + active_policy = storage.get_active_policy() or default_policy() + current_release_id = storage.get_promoted_release_id(agent_id, environment) + + policy_result: PolicyResult + if not current_release_id: + policy_result = PolicyResult( + passed=True, + reasons=["first promotion: no promoted baseline for agent/environment"], + ) + else: + baseline = storage.get_release(current_release_id) + if not baseline: + raise click.ClickException(f"Promoted baseline release is missing: {current_release_id}") + + baseline_artifact = ReleaseArtifact.model_validate(baseline.artifact_json) + baseline_pricing_ref = baseline_artifact.spec.pricing_reference + candidate_pricing_ref = target_artifact.spec.pricing_reference + + baseline_table = storage.get_pricing_table( + baseline_pricing_ref.provider, + baseline_pricing_ref.pricing_version, + ) + if not baseline_table: + raise click.ClickException( + "Missing pricing table for promoted baseline " + f"{baseline_pricing_ref.provider}/{baseline_pricing_ref.pricing_version}. " + "Run `flightdeck pricing import ...`." + ) + + candidate_table = storage.get_pricing_table( + candidate_pricing_ref.provider, + candidate_pricing_ref.pricing_version, + ) + if not candidate_table: + raise click.ClickException( + "Missing pricing table for candidate " + f"{candidate_pricing_ref.provider}/{candidate_pricing_ref.pricing_version}. " + "Run `flightdeck pricing import ...`." + ) + + try: + delta = parse_window(window) + except ValueError as e: + raise click.ClickException(str(e)) from e + until = utc_now() + since = until - delta + baseline_events = storage.query_runs( + current_release_id, + since, + until, + environment=environment, + ) + candidate_events = storage.query_runs( + release_id, + since, + until, + environment=environment, + ) + + try: + diff = diff_releases( + cfg=cfg, + policy=active_policy, + baseline_events=baseline_events, + candidate_events=candidate_events, + baseline_pricing_table=baseline_table, + candidate_pricing_table=candidate_table, + window=window, + ) + except KeyError as e: + raise click.ClickException( + "Pricing table missing model entry. " + f"baseline_model={baseline_artifact.spec.runtime.model} " + f"candidate_model={target_artifact.spec.runtime.model}." + ) from e + except ValueError as e: + raise click.ClickException(str(e)) from e + policy_result = diff.policy + + record = PromotionRecord( + action_id=f"act_{uuid4().hex[:12]}", + action="promote", + actor=actor_name(), + release_id=release_id, + agent_id=agent_id, + environment=environment, + reason=reason, + policy_result=policy_result, + baseline_release_id=current_release_id, + created_at=utc_now(), + ) + + if not policy_result.passed: + storage.insert_promotion_record(record) + click.echo("Policy: FAIL") + for r in policy_result.reasons: + click.echo(f"- {r}") + raise click.ClickException("Promotion blocked by policy") + + storage.commit_promotion(record, new_promoted_release_id=release_id) + click.echo(f"Promoted {release_id} for {agent_id}/{environment}") + click.echo("Policy: PASS") + for r in policy_result.reasons: + click.echo(f"- {r}") + + +@release.command("rollback") +@click.argument("release_id") +@click.option("--env", "environment", required=True) +@click.option("--window", required=True, help="Required. Time window like 7d, 24h, 30m.") +@click.option("--reason", required=True, help="Required rationale for the rollback decision.") +def release_rollback(release_id: str, environment: str, window: str, reason: str) -> None: + """Roll back to a prior release (audit record + promoted pointer update).""" + cfg = load_config() + storage = Storage(cfg.db_path) + storage.migrate() + + target = storage.get_release(release_id) + if not target: + raise click.ClickException(f"Unknown release: {release_id}") + + target_artifact = ReleaseArtifact.model_validate(target.artifact_json) + agent_id = target_artifact.spec.agent.agent_id + active_policy = storage.get_active_policy() or default_policy() + current_release_id = storage.get_promoted_release_id(agent_id, environment) + if not current_release_id: + raise click.ClickException("No promoted release exists for this agent/environment; nothing to roll back to.") + + baseline = storage.get_release(current_release_id) + if not baseline: + raise click.ClickException(f"Promoted baseline release is missing: {current_release_id}") + + baseline_artifact = ReleaseArtifact.model_validate(baseline.artifact_json) + baseline_pricing_ref = baseline_artifact.spec.pricing_reference + candidate_pricing_ref = target_artifact.spec.pricing_reference + + baseline_table = storage.get_pricing_table(baseline_pricing_ref.provider, baseline_pricing_ref.pricing_version) + if not baseline_table: + raise click.ClickException( + "Missing pricing table for promoted baseline " + f"{baseline_pricing_ref.provider}/{baseline_pricing_ref.pricing_version}. " + "Run `flightdeck pricing import ...`." + ) + + candidate_table = storage.get_pricing_table(candidate_pricing_ref.provider, candidate_pricing_ref.pricing_version) + if not candidate_table: + raise click.ClickException( + "Missing pricing table for rollback target " + f"{candidate_pricing_ref.provider}/{candidate_pricing_ref.pricing_version}. " + "Run `flightdeck pricing import ...`." + ) + + try: + delta = parse_window(window) + except ValueError as e: + raise click.ClickException(str(e)) from e + until = utc_now() + since = until - delta + + baseline_events = storage.query_runs( + current_release_id, + since, + until, + environment=environment, + ) + candidate_events = storage.query_runs( + release_id, + since, + until, + environment=environment, + ) + + try: + diff = diff_releases( + cfg=cfg, + policy=active_policy, + baseline_events=baseline_events, + candidate_events=candidate_events, + baseline_pricing_table=baseline_table, + candidate_pricing_table=candidate_table, + window=window, + ) + except KeyError as e: + raise click.ClickException( + "Pricing table missing model entry. " + f"baseline_model={baseline_artifact.spec.runtime.model} " + f"candidate_model={target_artifact.spec.runtime.model}." + ) from e + except ValueError as e: + raise click.ClickException(str(e)) from e + + policy_result = diff.policy + + record = PromotionRecord( + action_id=f"act_{uuid4().hex[:12]}", + action="rollback", + actor=actor_name(), + release_id=release_id, + agent_id=agent_id, + environment=environment, + reason=reason, + policy_result=policy_result, + baseline_release_id=current_release_id, + created_at=utc_now(), + ) + + if not policy_result.passed: + storage.insert_promotion_record(record) + click.echo("Policy: FAIL") + for r in policy_result.reasons: + click.echo(f"- {r}") + raise click.ClickException("Rollback blocked by policy") + + storage.commit_promotion(record, new_promoted_release_id=release_id) + click.echo(f"Rolled back to {release_id} for {agent_id}/{environment}") + click.echo("Policy: PASS") + for r in policy_result.reasons: + click.echo(f"- {r}") + + +@release.command("history") +@click.option("--agent", "agent_id", default=None) +@click.option("--env", "environment", default=None) +def release_history(agent_id: str | None, environment: str | None) -> None: + """Show release promotion decision history.""" + cfg = load_config() + storage = Storage(cfg.db_path) + storage.migrate() + + records = storage.list_release_actions(agent_id=agent_id, environment=environment) + for r in records: + status = "PASS" if r.policy_result.passed else "FAIL" + baseline = r.baseline_release_id or "-" + click.echo( + f"{r.created_at.isoformat()}\t{r.action}\t{status}\t" + f"{r.release_id}\tbaseline={baseline}\tactor={r.actor}\treason={r.reason}" + ) + for reason_text in r.policy_result.reasons: + click.echo(f" - {reason_text}") + + +if __name__ == "__main__": + cli() diff --git a/src/flightdeck/config.py b/src/flightdeck/config.py new file mode 100644 index 0000000..6bcfa3e --- /dev/null +++ b/src/flightdeck/config.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + +from flightdeck.models import WorkspaceConfig + + +DEFAULT_CONFIG_FILENAME = "flightdeck.yaml" + + +def config_path(start_dir: str | Path = ".") -> Path: + base = Path(start_dir).resolve() + return base / DEFAULT_CONFIG_FILENAME + + +def load_config(path: str | Path = DEFAULT_CONFIG_FILENAME) -> WorkspaceConfig: + p = Path(path) + if not p.exists(): + raise FileNotFoundError(f"Workspace config not found: {p}. Run `flightdeck init`.") + + data: Any + with p.open("r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + return WorkspaceConfig.model_validate(data) + + +def write_default_config(path: str | Path = DEFAULT_CONFIG_FILENAME) -> Path: + cfg = WorkspaceConfig() + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + + with p.open("w", encoding="utf-8", newline="\n") as f: + yaml.safe_dump(cfg.model_dump(mode="json"), f, sort_keys=False) + + return p + diff --git a/src/flightdeck/doctor.py b/src/flightdeck/doctor.py new file mode 100644 index 0000000..25135c8 --- /dev/null +++ b/src/flightdeck/doctor.py @@ -0,0 +1,59 @@ +"""Read-only workspace health checks (`flightdeck doctor`).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from flightdeck.storage import LATEST_SCHEMA_MIGRATION_VERSION, Storage + + +@dataclass(frozen=True) +class DoctorCheck: + """One named check; `ok` is True when the ledger looks consistent.""" + + name: str + ok: bool + detail: str + + +def run_doctor(storage: Storage) -> list[DoctorCheck]: + """ + Run local integrity checks. Mutates nothing beyond what `migrate()` does + (idempotent schema/index application). + """ + storage.migrate() + checks: list[DoctorCheck] = [] + + applied = set(storage.list_applied_migrations()) + expected = set(range(1, LATEST_SCHEMA_MIGRATION_VERSION + 1)) + missing = sorted(expected - applied) + checks.append( + DoctorCheck( + name="schema_migrations", + ok=len(missing) == 0, + detail=( + f"applied={sorted(applied)} expected 1..{LATEST_SCHEMA_MIGRATION_VERSION}" + if not missing + else f"missing migration versions: {missing} (applied={sorted(applied)})" + ), + ) + ) + + for agent_id, environment, release_id in storage.list_promoted_pointers(): + row = storage.get_release(release_id) + checks.append( + DoctorCheck( + name=f"promoted_pointer:{agent_id}:{environment}", + ok=row is not None, + detail=( + f"release_id={release_id} ok" + if row is not None + else f"release_id={release_id!r} missing from releases table" + ), + ) + ) + + ok, detail = storage.check_release_actions_audit_seq() + checks.append(DoctorCheck(name="audit_seq", ok=ok, detail=detail)) + + return checks diff --git a/src/flightdeck/ledger.py b/src/flightdeck/ledger.py new file mode 100644 index 0000000..5da122a --- /dev/null +++ b/src/flightdeck/ledger.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +from typing import Literal + +from flightdeck.models import Policy, PolicyResult, PricingEntry, PricingTable, RunEvent, WorkspaceConfig + + +def parse_window(window: str) -> timedelta: + """ + Parse a simple window string like '7d', '24h', '30m'. + """ + window = window.strip().lower() + if len(window) < 2: + raise ValueError(f"Invalid window: {window}") + + unit = window[-1] + try: + value = int(window[:-1]) + except ValueError as e: + raise ValueError(f"Invalid window: {window}") from e + if value <= 0: + raise ValueError(f"Invalid window: {window}") + + if unit == "d": + return timedelta(days=value) + if unit == "h": + return timedelta(hours=value) + if unit == "m": + return timedelta(minutes=value) + + raise ValueError(f"Invalid window unit: {window}") + + +def confidence_label( + baseline_runs: int, + candidate_runs: int, + *, + min_baseline_runs: int, + min_candidate_runs: int, + min_low_runs: int, +) -> Literal["HIGH", "MEDIUM", "LOW"]: + if baseline_runs >= min_baseline_runs and candidate_runs >= min_candidate_runs: + return "HIGH" + if baseline_runs < min_low_runs or candidate_runs < min_low_runs: + return "LOW" + return "MEDIUM" + + +def pricing_entry_for(table: PricingTable, model: str) -> PricingEntry | None: + for e in table.entries: + if e.model == model: + return e + return None + + +def estimate_cost_usd(event: RunEvent, pricing_table: PricingTable) -> float: + entry = pricing_entry_for(pricing_table, event.usage.model.model) + if entry is None: + raise KeyError(f"Pricing missing for model: {event.usage.model.model}") + + in_cost = (event.usage.model.input_tokens / 1000.0) * entry.input_usd_per_1k_tokens + out_cost = (event.usage.model.output_tokens / 1000.0) * entry.output_usd_per_1k_tokens + cached = 0.0 + if entry.cached_input_usd_per_1k_tokens is not None: + cached = (event.usage.model.cached_input_tokens / 1000.0) * entry.cached_input_usd_per_1k_tokens + return in_cost + out_cost + cached + + +@dataclass(frozen=True) +class Rollup: + runs: int + cost_per_run_usd: float + latency_ms_avg: float | None + error_rate: float + + +def compute_rollup(events: list[RunEvent], pricing_table: PricingTable) -> Rollup: + if not events: + return Rollup(runs=0, cost_per_run_usd=0.0, latency_ms_avg=None, error_rate=0.0) + + total_cost = 0.0 + total_latency = 0.0 + latency_count = 0 + error_count = 0 + + for e in events: + total_cost += estimate_cost_usd(e, pricing_table) + if e.metrics.latency_ms is not None: + total_latency += e.metrics.latency_ms + latency_count += 1 + if not e.metrics.success: + error_count += 1 + + return Rollup( + runs=len(events), + cost_per_run_usd=(total_cost / len(events)), + latency_ms_avg=(total_latency / latency_count) if latency_count else None, + error_rate=(error_count / len(events)), + ) + + +@dataclass(frozen=True) +class DiffResult: + baseline_runs: int + candidate_runs: int + window: str + confidence: Literal["HIGH", "MEDIUM", "LOW"] + confidence_reason: str | None + + baseline: Rollup + candidate: Rollup + + delta_cost_per_run_usd: float + delta_cost_per_run_pct: float | None + delta_latency_ms_avg: float | None + delta_error_rate: float + + policy: PolicyResult + + +def evaluate_policy( + policy: Policy, + *, + candidate: Rollup, + baseline: Rollup, + diff_confidence: Literal["HIGH", "MEDIUM", "LOW"], + diff_confidence_reason: str | None, +) -> PolicyResult: + reasons: list[str] = [] + + # Cost + if policy.max_cost_per_run_usd is not None and candidate.cost_per_run_usd > policy.max_cost_per_run_usd: + reasons.append( + f"candidate cost_per_run_usd {candidate.cost_per_run_usd:.6f} exceeds max {policy.max_cost_per_run_usd:.6f}" + ) + + # Latency + if policy.max_latency_ms is not None and candidate.latency_ms_avg is not None: + if candidate.latency_ms_avg > policy.max_latency_ms: + reasons.append( + f"candidate latency_ms_avg {candidate.latency_ms_avg:.2f} exceeds max {policy.max_latency_ms}" + ) + + # Error rate + if policy.max_error_rate is not None: + if candidate.error_rate > policy.max_error_rate: + reasons.append( + f"candidate error_rate {candidate.error_rate:.4f} exceeds max {policy.max_error_rate:.4f}" + ) + + if policy.require_high_diff_confidence and diff_confidence != "HIGH": + suffix = f" ({diff_confidence_reason})" if diff_confidence_reason else "" + reasons.append(f"diff confidence is {diff_confidence}{suffix}; promotion requires HIGH") + + return PolicyResult(passed=(len(reasons) == 0), reasons=reasons) + + +def diff_releases( + *, + cfg: WorkspaceConfig, + policy: Policy, + baseline_events: list[RunEvent], + candidate_events: list[RunEvent], + baseline_pricing_table: PricingTable, + candidate_pricing_table: PricingTable, + window: str, +) -> DiffResult: + if baseline_events and candidate_events: + b_agents = {e.agent_id for e in baseline_events} + c_agents = {e.agent_id for e in candidate_events} + if len(b_agents) != 1 or len(c_agents) != 1: + raise ValueError( + "Each side of the diff must have a single consistent agent_id among run events." + ) + if next(iter(b_agents)) != next(iter(c_agents)): + raise ValueError( + "Cross-agent diff rejected: baseline and candidate run events must share the same agent_id." + ) + + baseline_rollup = compute_rollup(baseline_events, baseline_pricing_table) + candidate_rollup = compute_rollup(candidate_events, candidate_pricing_table) + + # Confidence (policy can override thresholds; otherwise take config defaults) + min_candidate_runs = policy.min_candidate_runs or cfg.diff.min_candidate_runs + min_baseline_runs = policy.min_baseline_runs or cfg.diff.min_baseline_runs + min_low_runs = policy.min_low_runs or cfg.diff.min_low_runs + + label = confidence_label( + baseline_rollup.runs, + candidate_rollup.runs, + min_candidate_runs=min_candidate_runs, + min_baseline_runs=min_baseline_runs, + min_low_runs=min_low_runs, + ) + reason = None + if label != "HIGH": + parts: list[str] = [] + if candidate_rollup.runs < min_candidate_runs: + parts.append(f"candidate sample < {min_candidate_runs} runs") + if baseline_rollup.runs < min_baseline_runs: + parts.append(f"baseline sample < {min_baseline_runs} runs") + if candidate_rollup.runs < min_low_runs or baseline_rollup.runs < min_low_runs: + parts.append(f"LOW floor is {min_low_runs} runs") + reason = "; ".join(parts) if parts else "insufficient sample size" + + # Deltas + delta_cost = candidate_rollup.cost_per_run_usd - baseline_rollup.cost_per_run_usd + delta_cost_pct = None + if baseline_rollup.cost_per_run_usd > 0: + delta_cost_pct = delta_cost / baseline_rollup.cost_per_run_usd + + delta_latency = None + if baseline_rollup.latency_ms_avg is not None and candidate_rollup.latency_ms_avg is not None: + delta_latency = candidate_rollup.latency_ms_avg - baseline_rollup.latency_ms_avg + + delta_error_rate = candidate_rollup.error_rate - baseline_rollup.error_rate + + policy_result = evaluate_policy( + policy, + candidate=candidate_rollup, + baseline=baseline_rollup, + diff_confidence=label, + diff_confidence_reason=reason, + ) + + return DiffResult( + baseline_runs=baseline_rollup.runs, + candidate_runs=candidate_rollup.runs, + window=window, + confidence=label, + confidence_reason=reason, + baseline=baseline_rollup, + candidate=candidate_rollup, + delta_cost_per_run_usd=delta_cost, + delta_cost_per_run_pct=delta_cost_pct, + delta_latency_ms_avg=delta_latency, + delta_error_rate=delta_error_rate, + policy=policy_result, + ) + diff --git a/src/flightdeck/models.py b/src/flightdeck/models.py new file mode 100644 index 0000000..aee6ebe --- /dev/null +++ b/src/flightdeck/models.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class DiffConfig(BaseModel): + min_candidate_runs: int = 500 + min_baseline_runs: int = 500 + min_low_runs: int = 50 + + +class WorkspaceConfig(BaseModel): + api_version: Literal["v1"] = "v1" + kind: Literal["WorkspaceConfig"] = "WorkspaceConfig" + + db_path: str = ".flightdeck/flightdeck.db" + default_environment: str = "local" + + diff: DiffConfig = Field(default_factory=DiffConfig) + + +class PricingEntry(BaseModel): + model: str + input_usd_per_1k_tokens: float = Field(ge=0) + output_usd_per_1k_tokens: float = Field(ge=0) + cached_input_usd_per_1k_tokens: float | None = Field(default=None, ge=0) + + +class PricingTable(BaseModel): + provider: str + pricing_version: str + entries: list[PricingEntry] + + +class ReleasePricingReference(BaseModel): + provider: str + pricing_version: str + + +class ReleaseMetadata(BaseModel): + name: str + version: str + description: str | None = None + created_by: str | None = None + created_at: datetime | None = None + + +class ReleaseSpecAgent(BaseModel): + agent_id: str + entrypoint: str | None = None + + +class ReleaseSpecRuntime(BaseModel): + provider: str + model: str + temperature: float | None = None + max_output_tokens: int | None = None + + +class ReleaseSpecPrompts(BaseModel): + system_ref: str + template_refs: list[str] = Field(default_factory=list) + + +class ReleaseSpecTools(BaseModel): + manifest_ref: str | None = None + tool_names: list[str] = Field(default_factory=list) + + +class ReleaseSpecRoutingFallback(BaseModel): + model: str + on_error: bool = True + + +class ReleaseSpecRouting(BaseModel): + strategy: Literal["single_model", "fallback_model"] = "single_model" + fallback: ReleaseSpecRoutingFallback | None = None + + +class ReleaseSpecSafetyRetryPolicy(BaseModel): + max_retries: int = 0 + backoff_ms: int | None = None + + +class ReleaseSpecSafetyTimeouts(BaseModel): + model_call: int | None = None + tool_call: int | None = None + + +class ReleaseSpecSafety(BaseModel): + retry_policy: ReleaseSpecSafetyRetryPolicy = Field(default_factory=ReleaseSpecSafetyRetryPolicy) + timeouts_ms: ReleaseSpecSafetyTimeouts | None = None + + +class ReleaseSpec(BaseModel): + agent: ReleaseSpecAgent + runtime: ReleaseSpecRuntime + prompts: ReleaseSpecPrompts + tools: ReleaseSpecTools | None = None + routing: ReleaseSpecRouting | None = None + safety: ReleaseSpecSafety | None = None + pricing_reference: ReleasePricingReference + tags: dict[str, str] = Field(default_factory=dict) + + +class ReleaseArtifact(BaseModel): + api_version: Literal["v1"] = "v1" + kind: Literal["Release"] = "Release" + metadata: ReleaseMetadata + spec: ReleaseSpec + + +class RunEventRequest(BaseModel): + session_id: str | None = None + trace_id: str | None = None + span_id: str | None = None + + +class RunEventMetrics(BaseModel): + latency_ms: int | None = Field(default=None, ge=0) + success: bool = True + error_type: str | None = None + + +class RunEventModelUsage(BaseModel): + provider: str + model: str + input_tokens: int = Field(ge=0) + output_tokens: int = Field(ge=0) + cached_input_tokens: int = Field(default=0, ge=0) + + +class RunEventToolUsage(BaseModel): + tool_name: str + invocations: int = Field(default=0, ge=0) + cost_units: float = Field(default=0.0, ge=0) + + +class RunEventUsage(BaseModel): + model: RunEventModelUsage + tools: list[RunEventToolUsage] = Field(default_factory=list) + + +class RunEvent(BaseModel): + api_version: Literal["v1"] = "v1" + type: Literal["run_start", "run_end"] = "run_end" + timestamp: datetime + + workspace_id: str = "ws_local" + agent_id: str + release_id: str + run_id: str + + tenant_id: str + task_id: str + environment: str + + request: RunEventRequest | None = None + metrics: RunEventMetrics = Field(default_factory=RunEventMetrics) + usage: RunEventUsage + labels: dict[str, str] = Field(default_factory=dict) + + +class Policy(BaseModel): + policy_id: str = "default" + max_cost_per_run_usd: float | None = Field(default=None, ge=0) + max_latency_ms: int | None = Field(default=None, ge=0) + max_error_rate: float | None = Field(default=None, ge=0, le=1) + + min_candidate_runs: int | None = Field(default=None, ge=0) + min_baseline_runs: int | None = Field(default=None, ge=0) + min_low_runs: int | None = Field(default=None, ge=0) + + # When true, promotion decisions require HIGH diff confidence (not just threshold deltas). + require_high_diff_confidence: bool = True + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +class PolicyResult(BaseModel): + passed: bool + reasons: list[str] = Field(default_factory=list) + evaluated_at: datetime = Field(default_factory=utc_now) + + +class ReleaseRecord(BaseModel): + release_id: str + agent_id: str + version: str + environment: str + checksum: str + artifact_json: dict[str, Any] + created_at: datetime + + +class PromotionRecord(BaseModel): + action_id: str + action: Literal["promote", "rollback"] + actor: str + release_id: str + agent_id: str + environment: str + reason: str + policy_result: PolicyResult + baseline_release_id: str | None = None + created_at: datetime + # Assigned by storage on insert when None; monotonic for `flightdeck doctor` gap checks. + audit_seq: int | None = None + diff --git a/src/flightdeck/sdk/__init__.py b/src/flightdeck/sdk/__init__.py new file mode 100644 index 0000000..48dfb59 --- /dev/null +++ b/src/flightdeck/sdk/__init__.py @@ -0,0 +1 @@ +"""Small client helpers for emitting runtime evidence to FlightDeck.""" diff --git a/src/flightdeck/sdk/client.py b/src/flightdeck/sdk/client.py new file mode 100644 index 0000000..7edd2b8 --- /dev/null +++ b/src/flightdeck/sdk/client.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Iterable + +import httpx + +from flightdeck.models import RunEvent + + +class FlightdeckClient: + def __init__(self, base_url: str, *, timeout_s: float = 5.0, client: httpx.Client | None = None) -> None: + self._base_url = base_url.rstrip("/") + self._owns_client = client is None + self._client = client or httpx.Client(timeout=timeout_s) + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def ingest_run_events(self, events: Iterable[RunEvent]) -> int: + payload = {"events": [e.model_dump(mode="json") for e in events]} + resp = self._client.post(f"{self._base_url}/v1/events", json=payload) + resp.raise_for_status() + data = resp.json() + return int(data["inserted"]) diff --git a/src/flightdeck/server/__init__.py b/src/flightdeck/server/__init__.py new file mode 100644 index 0000000..8fb2013 --- /dev/null +++ b/src/flightdeck/server/__init__.py @@ -0,0 +1 @@ +"""Local HTTP services for FlightDeck (optional).""" diff --git a/src/flightdeck/server/app.py b/src/flightdeck/server/app.py new file mode 100644 index 0000000..f518c3f --- /dev/null +++ b/src/flightdeck/server/app.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, Field + +from flightdeck.config import load_config +from flightdeck.models import RunEvent +from flightdeck.storage import Storage + + +class IngestEventsRequest(BaseModel): + events: list[dict[str, Any]] = Field(min_length=1) + + +def create_app() -> FastAPI: + app = FastAPI(title="FlightDeck", version="local") + + @app.get("/health") + def health() -> dict[str, str]: + return {"status": "ok"} + + @app.post("/v1/events") + def ingest_events(req: IngestEventsRequest) -> dict[str, int]: + cfg = load_config() + storage = Storage(cfg.db_path) + storage.migrate() + + events: list[RunEvent] = [] + for item in req.events: + av = item.get("api_version", "v1") + if av != "v1": + raise HTTPException( + status_code=400, + detail=f"Unsupported api_version for POST /v1/events: {av!r} (only 'v1' is accepted).", + ) + try: + events.append(RunEvent.model_validate(item)) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid RunEvent: {e}") from e + + inserted = storage.insert_run_events(events) + return {"inserted": inserted} + + return app diff --git a/src/flightdeck/server/routes/__init__.py b/src/flightdeck/server/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/flightdeck/storage.py b/src/flightdeck/storage.py new file mode 100644 index 0000000..36b53af --- /dev/null +++ b/src/flightdeck/storage.py @@ -0,0 +1,632 @@ +from __future__ import annotations + +import hashlib +import json +import sqlite3 +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable +from uuid import uuid4 + +from flightdeck.models import Policy, PolicyResult, PricingTable, PromotionRecord, ReleaseRecord, RunEvent + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def ensure_parent_dir(db_path: str) -> None: + Path(db_path).expanduser().resolve().parent.mkdir(parents=True, exist_ok=True) + + +# Highest migration version applied by `Storage.migrate()` — keep in sync with migration blocks below. +LATEST_SCHEMA_MIGRATION_VERSION = 3 + + +@dataclass(frozen=True) +class Storage: + db_path: str + + @staticmethod + def _configure_connection(conn: sqlite3.Connection) -> None: + # Improve concurrency + reduce "database is locked" surprises on Windows. + conn.execute("PRAGMA foreign_keys=ON;") + conn.execute("PRAGMA journal_mode=WAL;") + conn.execute("PRAGMA synchronous=NORMAL;") + conn.execute("PRAGMA busy_timeout=5000;") + + def connect(self) -> sqlite3.Connection: + ensure_parent_dir(self.db_path) + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + self._configure_connection(conn) + return conn + + @contextmanager + def transaction(self) -> Any: + conn = self.connect() + try: + conn.execute("BEGIN IMMEDIATE;") + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + def migrate(self) -> None: + with self.connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY + ) + """ + ) + + conn.execute( + """ + CREATE TABLE IF NOT EXISTS releases ( + release_id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + version TEXT NOT NULL, + environment TEXT NOT NULL, + checksum TEXT NOT NULL, + artifact_json TEXT NOT NULL, + created_at TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS pricing_tables ( + provider TEXT NOT NULL, + pricing_version TEXT NOT NULL, + pricing_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (provider, pricing_version) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS pricing_import_audit ( + import_id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + pricing_version TEXT NOT NULL, + action TEXT NOT NULL, + actor TEXT NOT NULL, + reason TEXT, + old_pricing_json TEXT, + new_pricing_json TEXT NOT NULL, + new_checksum TEXT NOT NULL, + created_at TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS run_events ( + run_id TEXT PRIMARY KEY, + release_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + task_id TEXT NOT NULL, + environment TEXT NOT NULL, + timestamp TEXT NOT NULL, + event_json TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS active_policy ( + policy_id TEXT PRIMARY KEY, + policy_json TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS promoted_releases ( + agent_id TEXT NOT NULL, + environment TEXT NOT NULL, + release_id TEXT NOT NULL, + promoted_at TEXT NOT NULL, + PRIMARY KEY (agent_id, environment) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS release_actions ( + action_id TEXT PRIMARY KEY, + action TEXT NOT NULL, + actor TEXT NOT NULL, + release_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + environment TEXT NOT NULL, + reason TEXT NOT NULL, + policy_result_json TEXT NOT NULL, + baseline_release_id TEXT, + created_at TEXT NOT NULL + ) + """ + ) + + applied = {int(r["version"]) for r in conn.execute("SELECT version FROM schema_migrations").fetchall()} + + def apply(version: int, statements: list[str]) -> None: + if version in applied: + return + for stmt in statements: + conn.execute(stmt) + conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (version,)) + applied.add(version) + + # v1: reserved for the initial schema created via CREATE TABLE IF NOT EXISTS above. + apply(1, ["SELECT 1;"]) + + # v2: query performance for diff / run lookups by release. + apply( + 2, + [ + "CREATE INDEX IF NOT EXISTS idx_run_events_release_timestamp " + "ON run_events(release_id, timestamp);", + ], + ) + + # v3: monotonic audit_seq on release_actions (append-only promotion/rollback ledger). + if 3 not in applied: + cols = {r["name"] for r in conn.execute("PRAGMA table_info(release_actions)").fetchall()} + if "audit_seq" not in cols: + conn.execute("ALTER TABLE release_actions ADD COLUMN audit_seq INTEGER") + pending = conn.execute( + """ + SELECT action_id FROM release_actions + WHERE audit_seq IS NULL + ORDER BY created_at, action_id + """ + ).fetchall() + if pending: + base_row = conn.execute("SELECT COALESCE(MAX(audit_seq), 0) FROM release_actions").fetchone() + n = int(base_row[0]) if base_row is not None else 0 + for pr in pending: + n += 1 + conn.execute( + "UPDATE release_actions SET audit_seq = ? WHERE action_id = ?", + (n, pr["action_id"]), + ) + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_release_actions_audit_seq " + "ON release_actions(audit_seq)" + ) + conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (3,)) + applied.add(3) + + def list_applied_migrations(self) -> list[int]: + """Return applied schema migration versions (requires tables to exist; call `migrate()` first).""" + with self.connect() as conn: + rows = conn.execute("SELECT version FROM schema_migrations ORDER BY version").fetchall() + return [int(r["version"]) for r in rows] + + def list_promoted_pointers(self) -> list[tuple[str, str, str]]: + """Each tuple is (agent_id, environment, release_id).""" + with self.connect() as conn: + rows = conn.execute( + "SELECT agent_id, environment, release_id FROM promoted_releases ORDER BY agent_id, environment" + ).fetchall() + return [(str(r["agent_id"]), str(r["environment"]), str(r["release_id"])) for r in rows] + + def check_release_actions_audit_seq(self) -> tuple[bool, str]: + """ + Verify `audit_seq` is present, non-null, unique, and contiguous from 1..max + (best-effort tamper / partial-write detection for the append-only ledger). + """ + self.migrate() + with self.connect() as conn: + cols = {r["name"] for r in conn.execute("PRAGMA table_info(release_actions)").fetchall()} + if "audit_seq" not in cols: + return False, "release_actions has no audit_seq column (migrations incomplete?)" + rows = conn.execute("SELECT audit_seq FROM release_actions ORDER BY audit_seq").fetchall() + if not rows: + return True, "no release_actions rows" + seqs = [r["audit_seq"] for r in rows] + if any(s is None for s in seqs): + return False, "NULL audit_seq in release_actions" + ints = [int(s) for s in seqs] + m = max(ints) + want = set(range(1, m + 1)) + got = set(ints) + if want != got: + missing = sorted(want - got) + extra = sorted(got - want) + return False, f"expected contiguous 1..{m}; missing={missing} extra={extra} (got={sorted(got)})" + if len(ints) != len(set(ints)): + return False, "duplicate audit_seq values" + return True, f"contiguous 1..{m} ({len(ints)} row(s))" + + def insert_pricing_table( + self, + table: PricingTable, + *, + replace: bool = False, + actor: str, + reason: str | None = None, + ) -> None: + new_json = json.dumps(table.model_dump(mode="json"), sort_keys=True, separators=(",", ":")) + new_checksum = hashlib.sha256(new_json.encode("utf-8")).hexdigest() + + with self.transaction() as conn: + row = conn.execute( + """ + SELECT pricing_json FROM pricing_tables + WHERE provider = ? AND pricing_version = ? + """, + (table.provider, table.pricing_version), + ).fetchone() + + if row and not replace: + raise ValueError( + f"Pricing table already exists for {table.provider}/{table.pricing_version}. " + f"Use --replace to override." + ) + + old_json = str(row["pricing_json"]) if row else None + + if row and replace: + if not reason: + raise ValueError("--reason is required when using --replace for pricing imports.") + + conn.execute( + """ + UPDATE pricing_tables + SET pricing_json = ?, created_at = ? + WHERE provider = ? AND pricing_version = ? + """, + ( + new_json, + utc_now().isoformat(), + table.provider, + table.pricing_version, + ), + ) + action = "replace" + else: + conn.execute( + """ + INSERT INTO pricing_tables (provider, pricing_version, pricing_json, created_at) + VALUES (?, ?, ?, ?) + """, + ( + table.provider, + table.pricing_version, + new_json, + utc_now().isoformat(), + ), + ) + action = "insert" + + conn.execute( + """ + INSERT INTO pricing_import_audit + (import_id, provider, pricing_version, action, actor, reason, old_pricing_json, new_pricing_json, new_checksum, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + f"pim_{uuid4().hex[:12]}", + table.provider, + table.pricing_version, + action, + actor, + reason, + old_json, + new_json, + new_checksum, + utc_now().isoformat(), + ), + ) + + def insert_release(self, record: ReleaseRecord) -> None: + with self.transaction() as conn: + existing = conn.execute( + "SELECT 1 FROM releases WHERE release_id = ?", + (record.release_id,), + ).fetchone() + if existing: + raise ValueError(f"Release already exists: {record.release_id}") + + conn.execute( + """ + INSERT INTO releases + (release_id, agent_id, version, environment, checksum, artifact_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + record.release_id, + record.agent_id, + record.version, + record.environment, + record.checksum, + json.dumps(record.artifact_json, sort_keys=True), + record.created_at.isoformat(), + ), + ) + + def get_release(self, release_id: str) -> ReleaseRecord | None: + with self.connect() as conn: + row = conn.execute( + "SELECT * FROM releases WHERE release_id = ?", + (release_id,), + ).fetchone() + if not row: + return None + return ReleaseRecord( + release_id=row["release_id"], + agent_id=row["agent_id"], + version=row["version"], + environment=row["environment"], + checksum=row["checksum"], + artifact_json=json.loads(row["artifact_json"]), + created_at=datetime.fromisoformat(row["created_at"]), + ) + + def list_releases(self) -> list[ReleaseRecord]: + with self.connect() as conn: + rows = conn.execute( + "SELECT * FROM releases ORDER BY created_at DESC", + ).fetchall() + return [ + ReleaseRecord( + release_id=r["release_id"], + agent_id=r["agent_id"], + version=r["version"], + environment=r["environment"], + checksum=r["checksum"], + artifact_json=json.loads(r["artifact_json"]), + created_at=datetime.fromisoformat(r["created_at"]), + ) + for r in rows + ] + + def get_pricing_table(self, provider: str, pricing_version: str) -> PricingTable | None: + with self.connect() as conn: + row = conn.execute( + """ + SELECT pricing_json FROM pricing_tables + WHERE provider = ? AND pricing_version = ? + """, + (provider, pricing_version), + ).fetchone() + if not row: + return None + return PricingTable.model_validate_json(row["pricing_json"]) + + def set_active_policy(self, policy: Policy) -> None: + with self.connect() as conn: + conn.execute( + """ + INSERT INTO active_policy (policy_id, policy_json, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(policy_id) DO UPDATE SET + policy_json = excluded.policy_json, + updated_at = excluded.updated_at + """, + (policy.policy_id, policy.model_dump_json(), utc_now().isoformat()), + ) + + def get_active_policy(self) -> Policy | None: + with self.connect() as conn: + row = conn.execute( + """ + SELECT policy_json FROM active_policy + ORDER BY updated_at DESC + LIMIT 1 + """, + ).fetchone() + if not row: + return None + return Policy.model_validate_json(row["policy_json"]) + + def get_promoted_release_id(self, agent_id: str, environment: str) -> str | None: + with self.connect() as conn: + row = conn.execute( + """ + SELECT release_id FROM promoted_releases + WHERE agent_id = ? AND environment = ? + """, + (agent_id, environment), + ).fetchone() + if not row: + return None + return str(row["release_id"]) + + @staticmethod + def _set_promoted_release_conn(conn: sqlite3.Connection, agent_id: str, environment: str, release_id: str) -> None: + conn.execute( + """ + INSERT INTO promoted_releases (agent_id, environment, release_id, promoted_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(agent_id, environment) DO UPDATE SET + release_id = excluded.release_id, + promoted_at = excluded.promoted_at + """, + (agent_id, environment, release_id, utc_now().isoformat()), + ) + + def set_promoted_release(self, agent_id: str, environment: str, release_id: str) -> None: + with self.connect() as conn: + self._set_promoted_release_conn(conn, agent_id, environment, release_id) + + @staticmethod + def _next_audit_seq(conn: sqlite3.Connection) -> int: + row = conn.execute("SELECT COALESCE(MAX(audit_seq), 0) + 1 AS n FROM release_actions").fetchone() + return int(row["n"]) + + @staticmethod + def _insert_release_action_conn(conn: sqlite3.Connection, record: PromotionRecord) -> None: + audit_seq = record.audit_seq + if audit_seq is None: + audit_seq = Storage._next_audit_seq(conn) + conn.execute( + """ + INSERT INTO release_actions + ( + action_id, + action, + actor, + release_id, + agent_id, + environment, + reason, + policy_result_json, + baseline_release_id, + created_at, + audit_seq + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + record.action_id, + record.action, + record.actor, + record.release_id, + record.agent_id, + record.environment, + record.reason, + record.policy_result.model_dump_json(), + record.baseline_release_id, + record.created_at.isoformat(), + audit_seq, + ), + ) + + def insert_promotion_record(self, record: PromotionRecord) -> None: + with self.connect() as conn: + self._insert_release_action_conn(conn, record) + + def commit_promotion(self, record: PromotionRecord, *, new_promoted_release_id: str) -> None: + """ + Atomically persist the audit record and update the promoted pointer. + """ + with self.transaction() as conn: + self._insert_release_action_conn(conn, record) + self._set_promoted_release_conn(conn, record.agent_id, record.environment, new_promoted_release_id) + + def list_release_actions( + self, + agent_id: str | None = None, + environment: str | None = None, + ) -> list[PromotionRecord]: + clauses: list[str] = [] + params: list[Any] = [] + if agent_id: + clauses.append("agent_id = ?") + params.append(agent_id) + if environment: + clauses.append("environment = ?") + params.append(environment) + + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + + with self.connect() as conn: + rows = conn.execute( + f""" + SELECT * FROM release_actions + {where} + ORDER BY created_at DESC + """, + tuple(params), + ).fetchall() + + out: list[PromotionRecord] = [] + for r in rows: + audit_seq_v: int | None = None + if "audit_seq" in r.keys() and r["audit_seq"] is not None: + audit_seq_v = int(r["audit_seq"]) + out.append( + PromotionRecord( + action_id=r["action_id"], + action=r["action"], + actor=r["actor"], + release_id=r["release_id"], + agent_id=r["agent_id"], + environment=r["environment"], + reason=r["reason"], + policy_result=PolicyResult.model_validate_json(r["policy_result_json"]), + baseline_release_id=r["baseline_release_id"], + created_at=datetime.fromisoformat(r["created_at"]), + audit_seq=audit_seq_v, + ) + ) + return out + + def insert_run_events(self, events: Iterable[RunEvent]) -> int: + rows = [] + for e in events: + rows.append( + ( + e.run_id, + e.release_id, + e.agent_id, + e.tenant_id, + e.task_id, + e.environment, + e.timestamp.isoformat(), + e.model_dump_json(), + ) + ) + + with self.connect() as conn: + inserted = 0 + for row in rows: + try: + conn.execute( + """ + INSERT INTO run_events + (run_id, release_id, agent_id, tenant_id, task_id, environment, timestamp, event_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + row, + ) + inserted += 1 + except sqlite3.IntegrityError: + # idempotent ingestion + pass + return inserted + + def query_runs( + self, + release_id: str, + since: datetime, + until: datetime, + tenant_id: str | None = None, + task_id: str | None = None, + environment: str | None = None, + ) -> list[RunEvent]: + clauses: list[str] = ["release_id = ?", "timestamp >= ?", "timestamp < ?"] + params: list[Any] = [release_id, since.isoformat(), until.isoformat()] + + if tenant_id: + clauses.append("tenant_id = ?") + params.append(tenant_id) + if task_id: + clauses.append("task_id = ?") + params.append(task_id) + if environment: + clauses.append("environment = ?") + params.append(environment) + + where = " AND ".join(clauses) + + with self.connect() as conn: + rows = conn.execute( + f"SELECT event_json FROM run_events WHERE {where}", + tuple(params), + ).fetchall() + return [RunEvent.model_validate_json(r["event_json"]) for r in rows] + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3dbb1e6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path + + +def pytest_configure() -> None: + """ + Windows note: + + Some environments restrict the default OS temp directory used by pytest's `tmp_path` fixture. + Redirect pytest temp dirs into a repo-local `.tmp/` folder unless the user opts out. + """ + if os.environ.get("FLIGHTDECK_USE_SYSTEM_TEMP") == "1": + return + if sys.platform != "win32": + return + + tmp_dir = (Path.cwd() / ".tmp").resolve() + tmp_dir.mkdir(parents=True, exist_ok=True) + + os.environ.setdefault("TEMP", str(tmp_dir)) + os.environ.setdefault("TMP", str(tmp_dir)) + os.environ.setdefault("TMPDIR", str(tmp_dir)) diff --git a/tests/fixtures/golden_bundle/prompts/s.md b/tests/fixtures/golden_bundle/prompts/s.md new file mode 100644 index 0000000..c0d0fb4 --- /dev/null +++ b/tests/fixtures/golden_bundle/prompts/s.md @@ -0,0 +1,2 @@ +line1 +line2 diff --git a/tests/fixtures/golden_bundle/release.yaml b/tests/fixtures/golden_bundle/release.yaml new file mode 100644 index 0000000..11f2a5d --- /dev/null +++ b/tests/fixtures/golden_bundle/release.yaml @@ -0,0 +1,16 @@ +api_version: v1 +kind: Release +metadata: + name: t + version: '1' +spec: + agent: + agent_id: a + runtime: + provider: openai + model: m + prompts: + system_ref: prompts/s.md + pricing_reference: + provider: openai + pricing_version: p diff --git a/tests/fixtures/json/policy_minimal_v1.json b/tests/fixtures/json/policy_minimal_v1.json new file mode 100644 index 0000000..c77c6db --- /dev/null +++ b/tests/fixtures/json/policy_minimal_v1.json @@ -0,0 +1,4 @@ +{ + "policy_id": "default", + "require_high_diff_confidence": true +} diff --git a/tests/fixtures/json/pricing_table_minimal_v1.json b/tests/fixtures/json/pricing_table_minimal_v1.json new file mode 100644 index 0000000..75c9860 --- /dev/null +++ b/tests/fixtures/json/pricing_table_minimal_v1.json @@ -0,0 +1,11 @@ +{ + "provider": "openai", + "pricing_version": "openai-fixture-1", + "entries": [ + { + "model": "gpt-4.1-mini", + "input_usd_per_1k_tokens": 0.15, + "output_usd_per_1k_tokens": 0.6 + } + ] +} diff --git a/tests/fixtures/json/release_artifact_minimal_v1.json b/tests/fixtures/json/release_artifact_minimal_v1.json new file mode 100644 index 0000000..8fa9c3d --- /dev/null +++ b/tests/fixtures/json/release_artifact_minimal_v1.json @@ -0,0 +1,24 @@ +{ + "api_version": "v1", + "kind": "Release", + "metadata": { + "name": "fixture-agent", + "version": "1" + }, + "spec": { + "agent": { + "agent_id": "agent_fixture" + }, + "runtime": { + "provider": "openai", + "model": "gpt-4.1-mini" + }, + "prompts": { + "system_ref": "prompts/system.md" + }, + "pricing_reference": { + "provider": "openai", + "pricing_version": "openai-fixture-1" + } + } +} diff --git a/tests/fixtures/json/run_event_minimal_v1.json b/tests/fixtures/json/run_event_minimal_v1.json new file mode 100644 index 0000000..aef8a27 --- /dev/null +++ b/tests/fixtures/json/run_event_minimal_v1.json @@ -0,0 +1,28 @@ +{ + "api_version": "v1", + "type": "run_end", + "timestamp": "2026-04-30T12:00:00+00:00", + "workspace_id": "ws_fixture", + "agent_id": "agent_fixture", + "release_id": "rel_fixture", + "run_id": "run_fixture_1", + "tenant_id": "tenant_fixture", + "task_id": "task_fixture", + "environment": "local", + "metrics": { + "latency_ms": 10, + "success": true, + "error_type": null + }, + "usage": { + "model": { + "provider": "openai", + "model": "gpt-4.1-mini", + "input_tokens": 100, + "output_tokens": 50, + "cached_input_tokens": 0 + }, + "tools": [] + }, + "labels": {} +} diff --git a/tests/test_bundle_checksum.py b/tests/test_bundle_checksum.py new file mode 100644 index 0000000..0d5b9e6 --- /dev/null +++ b/tests/test_bundle_checksum.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from pathlib import Path + +from flightdeck.bundle import bundle_checksum + + +def _write_bundle(root: Path, *, newline: str) -> None: + (root / "prompts").mkdir(parents=True) + (root / "release.yaml").write_text( + "api_version: v1\nkind: Release\nmetadata:\n name: t\n version: '1'\n" + "spec:\n agent:\n agent_id: a\n runtime:\n provider: openai\n model: m\n" + " prompts:\n system_ref: prompts/s.md\n pricing_reference:\n provider: openai\n" + " pricing_version: p\n", + encoding="utf-8", + newline=newline, + ) + (root / "prompts" / "s.md").write_bytes(f"line1{newline}line2{newline}".encode("utf-8")) + + +def test_bundle_checksum_stable_across_crlf_and_lf(tmp_path: Path) -> None: + lf_dir = tmp_path / "lf" + crlf_dir = tmp_path / "crlf" + lf_dir.mkdir() + crlf_dir.mkdir() + _write_bundle(lf_dir, newline="\n") + _write_bundle(crlf_dir, newline="\r\n") + assert bundle_checksum(lf_dir) == bundle_checksum(crlf_dir) + + +def test_bundle_checksum_skips_git_dir(tmp_path: Path) -> None: + root = tmp_path / "b" + _write_bundle(root, newline="\n") + without_git = bundle_checksum(root) + (root / ".git").mkdir() + (root / ".git" / "config").write_text("[core]\n\trepositoryformatversion = 0\n", encoding="utf-8") + with_git = bundle_checksum(root) + assert with_git == without_git diff --git a/tests/test_bundle_golden_fixture.py b/tests/test_bundle_golden_fixture.py new file mode 100644 index 0000000..f4ad2bd --- /dev/null +++ b/tests/test_bundle_golden_fixture.py @@ -0,0 +1,30 @@ +"""Pinned SHA-256 for committed bundle fixture (CI + cross-OS regression).""" + +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +import pytest + +from flightdeck.bundle import bundle_checksum + +GOLDEN_BUNDLE_DIR = Path(__file__).resolve().parent / "fixtures" / "golden_bundle" +# Pinned digest for tests/fixtures/golden_bundle (LF, UTF-8); see canonical v1-next-steps / golden bundle CI notes. +GOLDEN_BUNDLE_SHA256 = "d016ae32d4a32667757618f8b76202ff0dfd317b3788401e3ae40622b266469d" + + +def test_committed_golden_bundle_matches_pinned_sha256() -> None: + assert GOLDEN_BUNDLE_DIR.is_dir() + assert bundle_checksum(GOLDEN_BUNDLE_DIR) == GOLDEN_BUNDLE_SHA256 + + +@pytest.mark.skipif(os.name == "nt", reason="symlink creation often requires elevated/dev mode on Windows") +def test_bundle_checksum_ignores_symlinks(tmp_path: Path) -> None: + shutil.copytree(GOLDEN_BUNDLE_DIR, tmp_path / "b") + root = tmp_path / "b" + c1 = bundle_checksum(root) + (root / "prompts" / "link.md").symlink_to(root / "prompts" / "s.md") + c2 = bundle_checksum(root) + assert c1 == c2, "symlinks must not affect bundle_checksum (determinism + security)" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..78cdf52 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,19 @@ +from click.testing import CliRunner + +from flightdeck import __version__ +from flightdeck.cli.main import cli + + +def test_cli_help() -> None: + result = CliRunner().invoke(cli, ["--help"]) + + assert result.exit_code == 0 + assert "FlightDeck" in result.output + assert "AI Release Governance" in result.output + + +def test_cli_version() -> None: + result = CliRunner().invoke(cli, ["--version"]) + + assert result.exit_code == 0 + assert __version__ in result.output diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 0000000..834e699 --- /dev/null +++ b/tests/test_doctor.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +from click.testing import CliRunner + +from flightdeck.cli.main import cli + +from tests.test_spine import write_events, write_policy, write_pricing, write_release + + +def test_doctor_passes_after_init(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["init"]).exit_code == 0 + res = runner.invoke(cli, ["doctor"]) + assert res.exit_code == 0 + assert "schema_migrations" in res.output + assert "all passed" in res.output.lower() + + +def test_doctor_audit_seq_ok_after_two_promotions(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["init"]).exit_code == 0 + policy = write_policy(tmp_path, max_cost_per_run_usd=10.0) + assert runner.invoke(cli, ["policy", "set", str(policy)]).exit_code == 0 + pricing = write_pricing(tmp_path, provider="openai", pricing_version="openai-2026-04-30") + assert runner.invoke(cli, ["pricing", "import", str(pricing)]).exit_code == 0 + + baseline_dir = write_release( + tmp_path, + agent_id="agent_support", + version="1", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + candidate_dir = write_release( + tmp_path, + agent_id="agent_support", + version="2", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + baseline_id = runner.invoke(cli, ["release", "register", str(baseline_dir)]).output.strip() + candidate_id = runner.invoke(cli, ["release", "register", str(candidate_dir)]).output.strip() + + now = datetime.now(tz=timezone.utc) + be = write_events(tmp_path, release_id=baseline_id, agent_id="agent_support", n=5, ts=now) + ce = write_events(tmp_path, release_id=candidate_id, agent_id="agent_support", n=5, ts=now) + assert runner.invoke(cli, ["runs", "ingest", str(be)]).exit_code == 0 + assert runner.invoke(cli, ["runs", "ingest", str(ce)]).exit_code == 0 + + assert ( + runner.invoke( + cli, + [ + "release", + "promote", + baseline_id, + "--env", + "local", + "--window", + "7d", + "--reason", + "baseline", + ], + ).exit_code + == 0 + ) + assert ( + runner.invoke( + cli, + [ + "release", + "promote", + candidate_id, + "--env", + "local", + "--window", + "7d", + "--reason", + "roll", + ], + ).exit_code + == 0 + ) + + res = runner.invoke(cli, ["doctor"]) + assert res.exit_code == 0 + assert "audit_seq" in res.output + assert "contiguous" in res.output.lower() + + +def test_doctor_fails_on_audit_seq_gap(tmp_path: Path, monkeypatch) -> None: + test_doctor_audit_seq_ok_after_two_promotions(tmp_path, monkeypatch) + db_path = tmp_path / ".flightdeck" / "flightdeck.db" + conn = sqlite3.connect(db_path) + conn.execute("UPDATE release_actions SET audit_seq = 99 WHERE audit_seq = 2") + conn.commit() + conn.close() + + res = CliRunner().invoke(cli, ["doctor"]) + assert res.exit_code != 0 + assert "audit_seq" in res.output.lower() + + +def test_doctor_fails_when_promoted_release_missing(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["init"]).exit_code == 0 + assert runner.invoke(cli, ["doctor"]).exit_code == 0 + + db_path = tmp_path / ".flightdeck" / "flightdeck.db" + assert db_path.is_file() + conn = sqlite3.connect(db_path) + conn.execute( + """ + INSERT INTO promoted_releases (agent_id, environment, release_id, promoted_at) + VALUES (?, ?, ?, ?) + """, + ("agent_x", "staging", "rel_missing", "2020-01-01T00:00:00+00:00"), + ) + conn.commit() + conn.close() + + res = runner.invoke(cli, ["doctor"]) + assert res.exit_code != 0 + assert "rel_missing" in res.output or "missing" in res.output.lower() diff --git a/tests/test_examples_parse.py b/tests/test_examples_parse.py new file mode 100644 index 0000000..5ffdd54 --- /dev/null +++ b/tests/test_examples_parse.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import yaml + +from flightdeck.models import ReleaseArtifact + + +def _iter_example_files() -> list[Path]: + root = Path(__file__).resolve().parents[1] / "examples" + paths: list[Path] = [] + paths.extend(root.rglob("*.yaml")) + paths.extend(root.rglob("*.yml")) + paths.extend(root.rglob("*.jsonl")) + return sorted(paths) + + +def test_example_yaml_files_parse_as_releases_or_pricing_or_policy() -> None: + for path in _iter_example_files(): + if path.suffix.lower() not in {".yaml", ".yml"}: + continue + text = path.read_text(encoding="utf-8") + data = yaml.safe_load(text) + assert isinstance(data, dict) + + kind = data.get("kind") + if kind == "Release": + ReleaseArtifact.model_validate(data) + else: + # pricing/policy examples are validated elsewhere; ensure YAML loads cleanly. + assert kind in {None, "WorkspaceConfig"} or "provider" in data or "policy_id" in data + + +def test_example_jsonl_lines_parse_as_json() -> None: + for path in _iter_example_files(): + if path.suffix.lower() != ".jsonl": + continue + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + json.loads(line) diff --git a/tests/test_ledger.py b/tests/test_ledger.py new file mode 100644 index 0000000..aad8e8c --- /dev/null +++ b/tests/test_ledger.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from flightdeck.ledger import diff_releases +from flightdeck.models import ( + Policy, + PricingEntry, + PricingTable, + RunEvent, + RunEventMetrics, + RunEventModelUsage, + RunEventUsage, + WorkspaceConfig, +) + + +def _event(*, agent_id: str, run_id: str, release_id: str) -> RunEvent: + return RunEvent( + timestamp=datetime.now(tz=timezone.utc), + agent_id=agent_id, + release_id=release_id, + run_id=run_id, + tenant_id="t", + task_id="task", + environment="local", + usage=RunEventUsage( + model=RunEventModelUsage( + provider="openai", + model="gpt-4.1-mini", + input_tokens=100, + output_tokens=50, + ) + ), + metrics=RunEventMetrics(latency_ms=100, success=True), + ) + + +def _pricing_table() -> PricingTable: + return PricingTable( + provider="openai", + pricing_version="p", + entries=[ + PricingEntry( + model="gpt-4.1-mini", + input_usd_per_1k_tokens=1.0, + output_usd_per_1k_tokens=2.0, + ) + ], + ) + + +def test_diff_releases_rejects_cross_agent_run_events() -> None: + cfg = WorkspaceConfig() + policy = Policy(require_high_diff_confidence=False) + b = _event(agent_id="agent_a", run_id="r1", release_id="rel_b") + c = _event(agent_id="agent_b", run_id="r2", release_id="rel_c") + table = _pricing_table() + + with pytest.raises(ValueError, match="Cross-agent diff rejected"): + diff_releases( + cfg=cfg, + policy=policy, + baseline_events=[b], + candidate_events=[c], + baseline_pricing_table=table, + candidate_pricing_table=table, + window="7d", + ) + + +def test_diff_releases_rejects_mixed_agents_within_side() -> None: + cfg = WorkspaceConfig() + policy = Policy(require_high_diff_confidence=False) + b1 = _event(agent_id="agent_a", run_id="r1", release_id="rel_b") + b2 = _event(agent_id="agent_x", run_id="r2", release_id="rel_b") + c1 = _event(agent_id="agent_a", run_id="r3", release_id="rel_c") + table = _pricing_table() + + with pytest.raises(ValueError, match="single consistent agent_id"): + diff_releases( + cfg=cfg, + policy=policy, + baseline_events=[b1, b2], + candidate_events=[c1], + baseline_pricing_table=table, + candidate_pricing_table=table, + window="7d", + ) diff --git a/tests/test_quickstart_smoke.py b/tests/test_quickstart_smoke.py new file mode 100644 index 0000000..9d11311 --- /dev/null +++ b/tests/test_quickstart_smoke.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +def test_quickstart_smoke_script_exits_zero() -> None: + root = Path(__file__).resolve().parents[1] + script = root / "scripts" / "quickstart_smoke.py" + proc = subprocess.run( + [sys.executable, str(script)], + cwd=root, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + assert proc.returncode == 0, proc.stderr + proc.stdout diff --git a/tests/test_release_verify.py b/tests/test_release_verify.py new file mode 100644 index 0000000..473c144 --- /dev/null +++ b/tests/test_release_verify.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +import yaml +from click.testing import CliRunner + +from flightdeck.cli.main import cli + +FIXTURE = Path(__file__).resolve().parent / "fixtures" / "golden_bundle" + + +def test_release_verify_ok(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + bundle = tmp_path / "bundle" + shutil.copytree(FIXTURE, bundle) + + assert runner.invoke(cli, ["init"]).exit_code == 0 + pricing = { + "provider": "openai", + "pricing_version": "p", + "entries": [{"model": "m", "input_usd_per_1k_tokens": 1.0, "output_usd_per_1k_tokens": 2.0}], + } + pp = tmp_path / "pricing.yaml" + pp.write_text(yaml.safe_dump(pricing, sort_keys=False), encoding="utf-8") + assert runner.invoke(cli, ["pricing", "import", str(pp)]).exit_code == 0 + + rid = runner.invoke(cli, ["release", "register", str(bundle)]).output.strip() + res = runner.invoke(cli, ["release", "verify", rid, "--path", str(bundle)]) + assert res.exit_code == 0 + assert "OK: checksum matches" in res.output + + +def test_release_verify_exit_2_on_mismatch(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + bundle = tmp_path / "bundle" + shutil.copytree(FIXTURE, bundle) + + assert runner.invoke(cli, ["init"]).exit_code == 0 + pricing = { + "provider": "openai", + "pricing_version": "p", + "entries": [{"model": "m", "input_usd_per_1k_tokens": 1.0, "output_usd_per_1k_tokens": 2.0}], + } + pp = tmp_path / "pricing.yaml" + pp.write_text(yaml.safe_dump(pricing, sort_keys=False), encoding="utf-8") + assert runner.invoke(cli, ["pricing", "import", str(pp)]).exit_code == 0 + + rid = runner.invoke(cli, ["release", "register", str(bundle)]).output.strip() + (bundle / "prompts" / "s.md").write_text("tampered\n", encoding="utf-8", newline="\n") + res = runner.invoke(cli, ["release", "verify", rid, "--path", str(bundle)]) + assert res.exit_code == 2 + assert "CHECKSUM MISMATCH" in res.output diff --git a/tests/test_schemas.py b/tests/test_schemas.py new file mode 100644 index 0000000..0a4188f --- /dev/null +++ b/tests/test_schemas.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from pydantic import BaseModel + +from flightdeck.models import Policy, PricingTable, ReleaseArtifact, RunEvent + + +def _read_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +_FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "json" + + +@pytest.mark.parametrize( + ("filename", "model"), + [ + ("run_event_minimal_v1.json", RunEvent), + ("release_artifact_minimal_v1.json", ReleaseArtifact), + ("pricing_table_minimal_v1.json", PricingTable), + ("policy_minimal_v1.json", Policy), + ], +) +def test_minimal_json_fixture_validates(filename: str, model: type[BaseModel]) -> None: + data = _read_json(_FIXTURE_DIR / filename) + model.model_validate(data) + + +def test_committed_json_schemas_match_models() -> None: + root = Path(__file__).resolve().parents[1] / "schemas" / "v1" + + assert _read_json(root / "release.schema.json") == ReleaseArtifact.model_json_schema() + assert _read_json(root / "run_event.schema.json") == RunEvent.model_json_schema() + assert _read_json(root / "pricing_table.schema.json") == PricingTable.model_json_schema() + assert _read_json(root / "policy.schema.json") == Policy.model_json_schema() diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py new file mode 100644 index 0000000..5f94f8f --- /dev/null +++ b/tests/test_sdk_client.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone + +import httpx + +from flightdeck.models import RunEvent, RunEventModelUsage, RunEventUsage +from flightdeck.sdk.client import FlightdeckClient + + +def test_flightdeck_client_ingest_uses_post_v1_events() -> None: + captured: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/v1/events" + captured["body"] = json.loads(request.content.decode("utf-8")) + events = captured["body"]["events"] + assert isinstance(events, list) + return httpx.Response(200, json={"inserted": len(events)}) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="http://flightdeck.test") as http: + client = FlightdeckClient("http://flightdeck.test", client=http) + now = datetime.now(tz=timezone.utc) + event = RunEvent( + timestamp=now, + agent_id="agent_support", + release_id="rel_test", + run_id="run_sdk_mock", + tenant_id="tenant_acme", + task_id="task_1", + environment="local", + usage=RunEventUsage( + model=RunEventModelUsage( + provider="openai", + model="gpt-4.1-mini", + input_tokens=10, + output_tokens=5, + ) + ), + ) + inserted = client.ingest_run_events([event]) + assert inserted == 1 + + body = captured["body"] + assert isinstance(body, dict) + events_out = body["events"] + assert len(events_out) == 1 + assert events_out[0]["run_id"] == "run_sdk_mock" + assert events_out[0]["api_version"] == "v1" diff --git a/tests/test_server_ingest.py b/tests/test_server_ingest.py new file mode 100644 index 0000000..3865923 --- /dev/null +++ b/tests/test_server_ingest.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path + +import yaml +from click.testing import CliRunner +from fastapi.testclient import TestClient + +from flightdeck.cli.main import cli +from flightdeck.server.app import create_app + + +def test_post_v1_events_ingests(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["init"]).exit_code == 0 + + pricing = { + "provider": "openai", + "pricing_version": "openai-2026-04-30", + "entries": [ + {"model": "gpt-4.1-mini", "input_usd_per_1k_tokens": 1.0, "output_usd_per_1k_tokens": 2.0}, + ], + } + pricing_path = tmp_path / "pricing.yaml" + pricing_path.write_text(yaml.safe_dump(pricing, sort_keys=False), encoding="utf-8") + assert runner.invoke(cli, ["pricing", "import", str(pricing_path)]).exit_code == 0 + + rel_dir = tmp_path / "release" + rel_dir.mkdir() + (rel_dir / "prompts").mkdir() + (rel_dir / "prompts" / "system.md").write_text("system", encoding="utf-8") + release = { + "api_version": "v1", + "kind": "Release", + "metadata": {"name": "support-agent", "version": "1"}, + "spec": { + "agent": {"agent_id": "agent_support"}, + "runtime": {"provider": "openai", "model": "gpt-4.1-mini"}, + "prompts": {"system_ref": "prompts/system.md"}, + "pricing_reference": {"provider": "openai", "pricing_version": "openai-2026-04-30"}, + }, + } + (rel_dir / "release.yaml").write_text(yaml.safe_dump(release, sort_keys=False), encoding="utf-8") + release_id = runner.invoke(cli, ["release", "register", str(rel_dir)]).output.strip() + + app = create_app() + client = TestClient(app) + + now = datetime.now(tz=timezone.utc).isoformat() + event = { + "api_version": "v1", + "type": "run_end", + "timestamp": now, + "workspace_id": "ws_local", + "agent_id": "agent_support", + "release_id": release_id, + "run_id": "http-ingest-1", + "tenant_id": "tenant_acme", + "task_id": "task_support", + "environment": "local", + "metrics": {"latency_ms": 1000, "success": True, "error_type": None}, + "usage": { + "model": { + "provider": "openai", + "model": "gpt-4.1-mini", + "input_tokens": 1000, + "output_tokens": 500, + "cached_input_tokens": 0, + }, + "tools": [], + }, + "labels": {"test": "server"}, + } + + resp = client.post("/v1/events", json={"events": [event]}) + assert resp.status_code == 200 + assert resp.json() == {"inserted": 1} + + +def test_post_v1_events_rejects_non_v1_api_version(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["init"]).exit_code == 0 + + app = create_app() + client = TestClient(app) + now = datetime.now(tz=timezone.utc).isoformat() + event = { + "api_version": "v2", + "type": "run_end", + "timestamp": now, + "workspace_id": "ws_local", + "agent_id": "agent_support", + "release_id": "rel_x", + "run_id": "bad-api-1", + "tenant_id": "tenant_acme", + "task_id": "task_support", + "environment": "local", + "metrics": {"latency_ms": 1000, "success": True, "error_type": None}, + "usage": { + "model": { + "provider": "openai", + "model": "gpt-4.1-mini", + "input_tokens": 1000, + "output_tokens": 500, + "cached_input_tokens": 0, + }, + "tools": [], + }, + "labels": {}, + } + resp = client.post("/v1/events", json={"events": [event]}) + assert resp.status_code == 400 + assert resp.json()["detail"] == ( + "Unsupported api_version for POST /v1/events: 'v2' (only 'v1' is accepted)." + ) + + +def _make_run_event_dict(*, api_version: str | None = "v1") -> dict: + now = datetime.now(tz=timezone.utc).isoformat() + return { + "api_version": api_version, + "type": "run_end", + "timestamp": now, + "workspace_id": "ws_local", + "agent_id": "agent_support", + "release_id": "rel_x", + "run_id": "bad-api-edge", + "tenant_id": "tenant_acme", + "task_id": "task_support", + "environment": "local", + "metrics": {"latency_ms": 1000, "success": True, "error_type": None}, + "usage": { + "model": { + "provider": "openai", + "model": "gpt-4.1-mini", + "input_tokens": 1000, + "output_tokens": 500, + "cached_input_tokens": 0, + }, + "tools": [], + }, + "labels": {}, + } + + +def test_post_v1_events_rejects_empty_api_version_string(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + assert CliRunner().invoke(cli, ["init"]).exit_code == 0 + app = create_app() + client = TestClient(app) + ev = _make_run_event_dict(api_version="") + resp = client.post("/v1/events", json={"events": [ev]}) + assert resp.status_code == 400 + assert resp.json()["detail"] == ( + "Unsupported api_version for POST /v1/events: '' (only 'v1' is accepted)." + ) + + +def test_post_v1_events_rejects_wrong_casing_v1(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + assert CliRunner().invoke(cli, ["init"]).exit_code == 0 + app = create_app() + client = TestClient(app) + ev = _make_run_event_dict(api_version="V1") + resp = client.post("/v1/events", json={"events": [ev]}) + assert resp.status_code == 400 + assert resp.json()["detail"] == ( + "Unsupported api_version for POST /v1/events: 'V1' (only 'v1' is accepted)." + ) + + +def test_post_v1_events_rejects_null_api_version(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + assert CliRunner().invoke(cli, ["init"]).exit_code == 0 + app = create_app() + client = TestClient(app) + ev = _make_run_event_dict() + ev["api_version"] = None + resp = client.post("/v1/events", json={"events": [ev]}) + assert resp.status_code == 400 + assert resp.json()["detail"] == ( + "Unsupported api_version for POST /v1/events: None (only 'v1' is accepted)." + ) + + +def test_post_v1_events_accepts_omitted_api_version(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["init"]).exit_code == 0 + + pricing = { + "provider": "openai", + "pricing_version": "openai-2026-04-30", + "entries": [ + {"model": "gpt-4.1-mini", "input_usd_per_1k_tokens": 1.0, "output_usd_per_1k_tokens": 2.0}, + ], + } + pricing_path = tmp_path / "pricing.yaml" + pricing_path.write_text(yaml.safe_dump(pricing, sort_keys=False), encoding="utf-8") + assert runner.invoke(cli, ["pricing", "import", str(pricing_path)]).exit_code == 0 + + rel_dir = tmp_path / "release" + rel_dir.mkdir() + (rel_dir / "prompts").mkdir() + (rel_dir / "prompts" / "system.md").write_text("system", encoding="utf-8") + release = { + "api_version": "v1", + "kind": "Release", + "metadata": {"name": "support-agent", "version": "1"}, + "spec": { + "agent": {"agent_id": "agent_support"}, + "runtime": {"provider": "openai", "model": "gpt-4.1-mini"}, + "prompts": {"system_ref": "prompts/system.md"}, + "pricing_reference": {"provider": "openai", "pricing_version": "openai-2026-04-30"}, + }, + } + (rel_dir / "release.yaml").write_text(yaml.safe_dump(release, sort_keys=False), encoding="utf-8") + release_id = runner.invoke(cli, ["release", "register", str(rel_dir)]).output.strip() + + app = create_app() + client = TestClient(app) + now = datetime.now(tz=timezone.utc).isoformat() + event = { + "type": "run_end", + "timestamp": now, + "workspace_id": "ws_local", + "agent_id": "agent_support", + "release_id": release_id, + "run_id": "http-omit-api-version", + "tenant_id": "tenant_acme", + "task_id": "task_support", + "environment": "local", + "metrics": {"latency_ms": 1000, "success": True, "error_type": None}, + "usage": { + "model": { + "provider": "openai", + "model": "gpt-4.1-mini", + "input_tokens": 1000, + "output_tokens": 500, + "cached_input_tokens": 0, + }, + "tools": [], + }, + "labels": {}, + } + + resp = client.post("/v1/events", json={"events": [event]}) + assert resp.status_code == 200 + assert resp.json() == {"inserted": 1} diff --git a/tests/test_spine.py b/tests/test_spine.py new file mode 100644 index 0000000..bb83bab --- /dev/null +++ b/tests/test_spine.py @@ -0,0 +1,769 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + +import yaml +from click.testing import CliRunner + +from flightdeck.config import load_config +from flightdeck.cli.main import bundle_checksum, cli +from flightdeck.ledger import confidence_label +from flightdeck.storage import Storage + + +def write_release( + tmp_path: Path, + *, + agent_id: str, + version: str, + pricing_provider: str, + pricing_version: str, + model: str = "gpt-4.1-mini", +) -> Path: + rel_dir = tmp_path / f"release_{version}" + rel_dir.mkdir() + (rel_dir / "prompts").mkdir() + (rel_dir / "prompts" / "system.md").write_text("system", encoding="utf-8") + release = { + "api_version": "v1", + "kind": "Release", + "metadata": {"name": "support-agent", "version": version}, + "spec": { + "agent": {"agent_id": agent_id}, + "runtime": {"provider": pricing_provider, "model": model}, + "prompts": {"system_ref": "prompts/system.md"}, + "pricing_reference": {"provider": pricing_provider, "pricing_version": pricing_version}, + }, + } + (rel_dir / "release.yaml").write_text(yaml.safe_dump(release, sort_keys=False), encoding="utf-8") + return rel_dir + + +def write_pricing( + tmp_path: Path, + *, + provider: str, + pricing_version: str, + model: str = "gpt-4.1-mini", + input_price: float = 1.0, + output_price: float = 2.0, +) -> Path: + p = tmp_path / f"pricing_{provider}_{pricing_version}.yaml" + data = { + "provider": provider, + "pricing_version": pricing_version, + "entries": [ + { + "model": model, + "input_usd_per_1k_tokens": input_price, + "output_usd_per_1k_tokens": output_price, + } + ], + } + p.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + return p + + +def write_events( + tmp_path: Path, + *, + release_id: str, + agent_id: str, + n: int, + ts: datetime, + model: str = "gpt-4.1-mini", +) -> Path: + p = tmp_path / f"events_{release_id}.jsonl" + lines = [] + for i in range(n): + e = { + "api_version": "v1", + "type": "run_end", + "timestamp": ts.isoformat(), + "workspace_id": "ws_local", + "agent_id": agent_id, + "release_id": release_id, + "run_id": f"{release_id}_{i}", + "tenant_id": "unknown", + "task_id": "unknown", + "environment": "local", + "metrics": {"latency_ms": 1000, "success": True, "error_type": None}, + "usage": { + "model": { + "provider": "openai", + "model": model, + "input_tokens": 1000, + "output_tokens": 500, + "cached_input_tokens": 0, + }, + "tools": [], + }, + "labels": {}, + } + lines.append(json.dumps(e)) + p.write_text("\n".join(lines) + "\n", encoding="utf-8") + return p + + +def write_policy( + tmp_path: Path, + *, + max_cost_per_run_usd: float | None = None, + require_high_diff_confidence: bool = False, +) -> Path: + p = tmp_path / "policy.yaml" + data: dict[str, object] = {"policy_id": "test-policy", "require_high_diff_confidence": require_high_diff_confidence} + if max_cost_per_run_usd is not None: + data["max_cost_per_run_usd"] = max_cost_per_run_usd + p.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + return p + + +def test_bundle_checksum_stable(tmp_path: Path) -> None: + rel = write_release(tmp_path, agent_id="agent_support", version="1", pricing_provider="openai", pricing_version="openai-2026-04-30") + c1 = bundle_checksum(rel) + c2 = bundle_checksum(rel) + assert c1 == c2 + + +def test_bundle_checksum_order_independent(tmp_path: Path) -> None: + rel_dir = tmp_path / "bundle" + rel_dir.mkdir() + (rel_dir / "prompts").mkdir() + (rel_dir / "prompts" / "b.md").write_text("b", encoding="utf-8") + (rel_dir / "prompts" / "a.md").write_text("a", encoding="utf-8") + release = { + "api_version": "v1", + "kind": "Release", + "metadata": {"name": "support-agent", "version": "1"}, + "spec": { + "agent": {"agent_id": "agent_support"}, + "runtime": {"provider": "openai", "model": "gpt-4.1-mini"}, + "prompts": {"system_ref": "prompts/a.md"}, + "pricing_reference": {"provider": "openai", "pricing_version": "openai-2026-04-30"}, + }, + } + (rel_dir / "release.yaml").write_text(yaml.safe_dump(release, sort_keys=False), encoding="utf-8") + + assert bundle_checksum(rel_dir) == bundle_checksum(rel_dir) + + +def test_release_diff_invalid_window(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["init"]).exit_code == 0 + + pricing = write_pricing(tmp_path, provider="openai", pricing_version="openai-2026-04-30") + assert runner.invoke(cli, ["pricing", "import", str(pricing)]).exit_code == 0 + + r1_dir = write_release(tmp_path, agent_id="agent_support", version="1", pricing_provider="openai", pricing_version="openai-2026-04-30") + r2_dir = write_release(tmp_path, agent_id="agent_support", version="2", pricing_provider="openai", pricing_version="openai-2026-04-30") + rel1 = runner.invoke(cli, ["release", "register", str(r1_dir)]).output.strip() + rel2 = runner.invoke(cli, ["release", "register", str(r2_dir)]).output.strip() + + res = runner.invoke(cli, ["release", "diff", rel1, rel2, "--window", "not-a-window"]) + assert res.exit_code != 0 + assert "Invalid window" in res.output + + +def test_pricing_replace_requires_reason(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["init"]).exit_code == 0 + + pricing = write_pricing(tmp_path, provider="openai", pricing_version="openai-2026-04-30") + assert runner.invoke(cli, ["pricing", "import", str(pricing)]).exit_code == 0 + + res = runner.invoke(cli, ["pricing", "import", "--replace", str(pricing)]) + assert res.exit_code != 0 + assert "--reason is required" in res.output + + res = runner.invoke(cli, ["pricing", "import", "--replace", "--reason", "fix typo", str(pricing)]) + assert res.exit_code == 0 + + +def test_rollback_promotes_prior_release(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + assert runner.invoke(cli, ["init"]).exit_code == 0 + assert runner.invoke(cli, ["policy", "set", str(write_policy(tmp_path))]).exit_code == 0 + + pricing = write_pricing(tmp_path, provider="openai", pricing_version="openai-2026-04-30") + assert runner.invoke(cli, ["pricing", "import", str(pricing)]).exit_code == 0 + + baseline_dir = write_release(tmp_path, agent_id="agent_support", version="1", pricing_provider="openai", pricing_version="openai-2026-04-30") + candidate_dir = write_release(tmp_path, agent_id="agent_support", version="2", pricing_provider="openai", pricing_version="openai-2026-04-30") + baseline_id = runner.invoke(cli, ["release", "register", str(baseline_dir)]).output.strip() + candidate_id = runner.invoke(cli, ["release", "register", str(candidate_dir)]).output.strip() + + now = datetime.now(tz=timezone.utc) + baseline_events = write_events(tmp_path, release_id=baseline_id, agent_id="agent_support", n=5, ts=now) + candidate_events = write_events(tmp_path, release_id=candidate_id, agent_id="agent_support", n=5, ts=now) + assert runner.invoke(cli, ["runs", "ingest", str(baseline_events)]).exit_code == 0 + assert runner.invoke(cli, ["runs", "ingest", str(candidate_events)]).exit_code == 0 + + assert ( + runner.invoke( + cli, + ["release", "promote", baseline_id, "--env", "local", "--window", "7d", "--reason", "baseline"], + ).exit_code + == 0 + ) + assert ( + runner.invoke( + cli, + ["release", "promote", candidate_id, "--env", "local", "--window", "7d", "--reason", "roll forward"], + ).exit_code + == 0 + ) + + storage = Storage(load_config().db_path) + storage.migrate() + assert storage.get_promoted_release_id("agent_support", "local") == candidate_id + + assert ( + runner.invoke( + cli, + [ + "release", + "rollback", + baseline_id, + "--env", + "local", + "--window", + "7d", + "--reason", + "revert regression", + ], + ).exit_code + == 0 + ) + assert storage.get_promoted_release_id("agent_support", "local") == baseline_id + + hist = runner.invoke(cli, ["release", "history", "--agent", "agent_support", "--env", "local"]) + assert hist.exit_code == 0 + assert "rollback" in hist.output + +def test_confidence_labels() -> None: + assert ( + confidence_label(500, 500, min_baseline_runs=500, min_candidate_runs=500, min_low_runs=50) == "HIGH" + ) + assert ( + confidence_label(200, 200, min_baseline_runs=500, min_candidate_runs=500, min_low_runs=50) == "MEDIUM" + ) + assert ( + confidence_label(10, 200, min_baseline_runs=500, min_candidate_runs=500, min_low_runs=50) == "LOW" + ) + + +def test_end_to_end_local_diff(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + # init workspace config + res = runner.invoke(cli, ["init"]) + assert res.exit_code == 0 + assert (tmp_path / "flightdeck.yaml").exists() + + assert runner.invoke(cli, ["policy", "set", str(write_policy(tmp_path))]).exit_code == 0 + + # pricing import + pricing = write_pricing(tmp_path, provider="openai", pricing_version="openai-2026-04-30") + res = runner.invoke(cli, ["pricing", "import", str(pricing)]) + assert res.exit_code == 0 + + # duplicate import should fail unless --replace is passed + res = runner.invoke(cli, ["pricing", "import", str(pricing)]) + assert res.exit_code != 0 + res = runner.invoke(cli, ["pricing", "import", "--replace", "--reason", "test replace", str(pricing)]) + assert res.exit_code == 0 + + # register two releases + r1_dir = write_release(tmp_path, agent_id="agent_support", version="1", pricing_provider="openai", pricing_version="openai-2026-04-30") + r2_dir = write_release(tmp_path, agent_id="agent_support", version="2", pricing_provider="openai", pricing_version="openai-2026-04-30") + res1 = runner.invoke(cli, ["release", "register", str(r1_dir)]) + res2 = runner.invoke(cli, ["release", "register", str(r2_dir)]) + assert res1.exit_code == 0 + assert res2.exit_code == 0 + rel1 = res1.output.strip() + rel2 = res2.output.strip() + assert rel1.startswith("rel_") + assert rel2.startswith("rel_") + + res = runner.invoke(cli, ["release", "show", rel1]) + assert res.exit_code == 0 + assert '"release_id":' in res.output + assert rel1 in res.output + + # ingest sample events (low volume => LOW confidence) + now = datetime.now(tz=timezone.utc) + events1 = write_events(tmp_path, release_id=rel1, agent_id="agent_support", n=10, ts=now) + events2 = write_events(tmp_path, release_id=rel2, agent_id="agent_support", n=10, ts=now) + res = runner.invoke(cli, ["runs", "ingest", str(events1)]) + assert res.exit_code == 0 + res = runner.invoke(cli, ["runs", "ingest", str(events2)]) + assert res.exit_code == 0 + + res = runner.invoke(cli, ["release", "diff", rel1, rel2, "--window", "7d"]) + assert res.exit_code == 0 + assert "Confidence:" in res.output + assert "LOW" in res.output + assert "delta" in res.output + assert "Î" not in res.output + assert "Δ" not in res.output + + +def test_diff_rejects_cross_agent(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + res = runner.invoke(cli, ["init"]) + assert res.exit_code == 0 + + pricing = write_pricing(tmp_path, provider="openai", pricing_version="openai-2026-04-30") + res = runner.invoke(cli, ["pricing", "import", str(pricing)]) + assert res.exit_code == 0 + + r1_dir = write_release(tmp_path, agent_id="agent_one", version="1", pricing_provider="openai", pricing_version="openai-2026-04-30") + r2_dir = write_release(tmp_path, agent_id="agent_two", version="2", pricing_provider="openai", pricing_version="openai-2026-04-30") + rel1 = runner.invoke(cli, ["release", "register", str(r1_dir)]).output.strip() + rel2 = runner.invoke(cli, ["release", "register", str(r2_dir)]).output.strip() + + res = runner.invoke(cli, ["release", "diff", rel1, rel2, "--window", "7d"]) + assert res.exit_code != 0 + + +def test_pricing_show_and_missing_table_error(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + res = runner.invoke(cli, ["init"]) + assert res.exit_code == 0 + + pricing = write_pricing(tmp_path, provider="openai", pricing_version="openai-2026-04-30") + res = runner.invoke(cli, ["pricing", "import", str(pricing)]) + assert res.exit_code == 0 + + res = runner.invoke( + cli, + ["pricing", "show", "--provider", "openai", "--version", "openai-2026-04-30"], + ) + assert res.exit_code == 0 + assert '"provider": "openai"' in res.output + assert '"pricing_version": "openai-2026-04-30"' in res.output + + res = runner.invoke( + cli, + ["pricing", "show", "--provider", "openai", "--version", "missing-version"], + ) + assert res.exit_code != 0 + assert "Pricing table not found: openai/missing-version" in res.output + + +def test_diff_reports_missing_baseline_pricing_table(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + res = runner.invoke(cli, ["init"]) + assert res.exit_code == 0 + + candidate_pricing = write_pricing(tmp_path, provider="openai", pricing_version="candidate-pricing") + res = runner.invoke(cli, ["pricing", "import", str(candidate_pricing)]) + assert res.exit_code == 0 + + baseline_dir = write_release( + tmp_path, + agent_id="agent_support", + version="1", + pricing_provider="openai", + pricing_version="missing-baseline", + ) + candidate_dir = write_release( + tmp_path, + agent_id="agent_support", + version="2", + pricing_provider="openai", + pricing_version="candidate-pricing", + ) + baseline_id = runner.invoke(cli, ["release", "register", str(baseline_dir)]).output.strip() + candidate_id = runner.invoke(cli, ["release", "register", str(candidate_dir)]).output.strip() + + res = runner.invoke(cli, ["release", "diff", baseline_id, candidate_id, "--window", "7d"]) + assert res.exit_code != 0 + assert "Missing pricing table for baseline openai/missing-baseline" in res.output + + +def test_diff_reports_missing_candidate_pricing_table(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + res = runner.invoke(cli, ["init"]) + assert res.exit_code == 0 + + baseline_pricing = write_pricing(tmp_path, provider="openai", pricing_version="baseline-pricing") + res = runner.invoke(cli, ["pricing", "import", str(baseline_pricing)]) + assert res.exit_code == 0 + + baseline_dir = write_release( + tmp_path, + agent_id="agent_support", + version="1", + pricing_provider="openai", + pricing_version="baseline-pricing", + ) + candidate_dir = write_release( + tmp_path, + agent_id="agent_support", + version="2", + pricing_provider="openai", + pricing_version="missing-candidate", + ) + baseline_id = runner.invoke(cli, ["release", "register", str(baseline_dir)]).output.strip() + candidate_id = runner.invoke(cli, ["release", "register", str(candidate_dir)]).output.strip() + + res = runner.invoke(cli, ["release", "diff", baseline_id, candidate_id, "--window", "7d"]) + assert res.exit_code != 0 + assert "Missing pricing table for candidate openai/missing-candidate" in res.output + + +def test_diff_reports_missing_model_entry_in_pricing_table(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + res = runner.invoke(cli, ["init"]) + assert res.exit_code == 0 + + pricing = write_pricing( + tmp_path, + provider="openai", + pricing_version="openai-2026-04-30", + model="other-model", + ) + res = runner.invoke(cli, ["pricing", "import", str(pricing)]) + assert res.exit_code == 0 + + baseline_dir = write_release( + tmp_path, + agent_id="agent_support", + version="1", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + model="gpt-4.1-mini", + ) + candidate_dir = write_release( + tmp_path, + agent_id="agent_support", + version="2", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + model="gpt-4.1-mini", + ) + baseline_id = runner.invoke(cli, ["release", "register", str(baseline_dir)]).output.strip() + candidate_id = runner.invoke(cli, ["release", "register", str(candidate_dir)]).output.strip() + + now = datetime.now(tz=timezone.utc) + baseline_events = write_events( + tmp_path, + release_id=baseline_id, + agent_id="agent_support", + n=1, + ts=now, + model="gpt-4.1-mini", + ) + candidate_events = write_events( + tmp_path, + release_id=candidate_id, + agent_id="agent_support", + n=1, + ts=now, + model="gpt-4.1-mini", + ) + assert runner.invoke(cli, ["runs", "ingest", str(baseline_events)]).exit_code == 0 + assert runner.invoke(cli, ["runs", "ingest", str(candidate_events)]).exit_code == 0 + + res = runner.invoke(cli, ["release", "diff", baseline_id, candidate_id, "--window", "7d"]) + assert res.exit_code != 0 + assert "Pricing table missing model entry" in res.output + assert "baseline_model=gpt-4.1-mini" in res.output + assert "candidate_model=gpt-4.1-mini" in res.output + + +def test_diff_uses_separate_baseline_and_candidate_pricing_tables( + tmp_path: Path, + monkeypatch, +) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + res = runner.invoke(cli, ["init"]) + assert res.exit_code == 0 + + baseline_pricing = write_pricing( + tmp_path, + provider="openai", + pricing_version="baseline-pricing", + input_price=1.0, + output_price=2.0, + ) + candidate_pricing = write_pricing( + tmp_path, + provider="openai", + pricing_version="candidate-pricing", + input_price=3.0, + output_price=4.0, + ) + assert runner.invoke(cli, ["pricing", "import", str(baseline_pricing)]).exit_code == 0 + assert runner.invoke(cli, ["pricing", "import", str(candidate_pricing)]).exit_code == 0 + + baseline_dir = write_release( + tmp_path, + agent_id="agent_support", + version="1", + pricing_provider="openai", + pricing_version="baseline-pricing", + ) + candidate_dir = write_release( + tmp_path, + agent_id="agent_support", + version="2", + pricing_provider="openai", + pricing_version="candidate-pricing", + ) + baseline_id = runner.invoke(cli, ["release", "register", str(baseline_dir)]).output.strip() + candidate_id = runner.invoke(cli, ["release", "register", str(candidate_dir)]).output.strip() + + now = datetime.now(tz=timezone.utc) + baseline_events = write_events(tmp_path, release_id=baseline_id, agent_id="agent_support", n=1, ts=now) + candidate_events = write_events(tmp_path, release_id=candidate_id, agent_id="agent_support", n=1, ts=now) + assert runner.invoke(cli, ["runs", "ingest", str(baseline_events)]).exit_code == 0 + assert runner.invoke(cli, ["runs", "ingest", str(candidate_events)]).exit_code == 0 + + res = runner.invoke(cli, ["release", "diff", baseline_id, candidate_id, "--window", "7d"]) + assert res.exit_code == 0 + assert "Baseline pricing: openai/baseline-pricing" in res.output + assert "Candidate pricing: openai/candidate-pricing" in res.output + assert "NOTE: cost delta includes pricing/model assumption changes" in res.output + assert "Estimated model token cost/run (USD): 2.000000 -> 5.000000" in res.output + + +def test_policy_set_and_show(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + res = runner.invoke(cli, ["init"]) + assert res.exit_code == 0 + + policy = write_policy(tmp_path, max_cost_per_run_usd=1.5) + res = runner.invoke(cli, ["policy", "set", str(policy)]) + assert res.exit_code == 0 + assert "Set policy test-policy" in res.output + + res = runner.invoke(cli, ["policy", "show"]) + assert res.exit_code == 0 + assert '"policy_id": "test-policy"' in res.output + assert '"max_cost_per_run_usd": 1.5' in res.output + + +def test_first_promotion_requires_reason_and_records_history(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + assert runner.invoke(cli, ["init"]).exit_code == 0 + pricing = write_pricing(tmp_path, provider="openai", pricing_version="openai-2026-04-30") + assert runner.invoke(cli, ["pricing", "import", str(pricing)]).exit_code == 0 + + release_dir = write_release( + tmp_path, + agent_id="agent_support", + version="1", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + release_id = runner.invoke(cli, ["release", "register", str(release_dir)]).output.strip() + + res = runner.invoke(cli, ["release", "promote", release_id, "--env", "local", "--window", "7d"]) + assert res.exit_code != 0 + assert "Missing option '--reason'" in res.output + + res = runner.invoke( + cli, + [ + "release", + "promote", + release_id, + "--env", + "local", + "--window", + "7d", + "--reason", + "initial production baseline", + ], + ) + assert res.exit_code == 0 + assert f"Promoted {release_id}" in res.output + assert "Policy: PASS" in res.output + + res = runner.invoke(cli, ["release", "history", "--agent", "agent_support", "--env", "local"]) + assert res.exit_code == 0 + assert "promote" in res.output + assert "PASS" in res.output + assert "initial production baseline" in res.output + assert release_id in res.output + + +def test_second_promotion_fails_when_policy_fails_and_keeps_current_release( + tmp_path: Path, + monkeypatch, +) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + assert runner.invoke(cli, ["init"]).exit_code == 0 + policy = write_policy(tmp_path, max_cost_per_run_usd=1.0) + assert runner.invoke(cli, ["policy", "set", str(policy)]).exit_code == 0 + pricing = write_pricing(tmp_path, provider="openai", pricing_version="openai-2026-04-30") + assert runner.invoke(cli, ["pricing", "import", str(pricing)]).exit_code == 0 + + baseline_dir = write_release( + tmp_path, + agent_id="agent_support", + version="1", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + candidate_dir = write_release( + tmp_path, + agent_id="agent_support", + version="2", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + baseline_id = runner.invoke(cli, ["release", "register", str(baseline_dir)]).output.strip() + candidate_id = runner.invoke(cli, ["release", "register", str(candidate_dir)]).output.strip() + + now = datetime.now(tz=timezone.utc) + baseline_events = write_events(tmp_path, release_id=baseline_id, agent_id="agent_support", n=5, ts=now) + candidate_events = write_events(tmp_path, release_id=candidate_id, agent_id="agent_support", n=5, ts=now) + assert runner.invoke(cli, ["runs", "ingest", str(baseline_events)]).exit_code == 0 + assert runner.invoke(cli, ["runs", "ingest", str(candidate_events)]).exit_code == 0 + + res = runner.invoke( + cli, + [ + "release", + "promote", + baseline_id, + "--env", + "local", + "--window", + "7d", + "--reason", + "establish baseline", + ], + ) + assert res.exit_code == 0 + + res = runner.invoke( + cli, + [ + "release", + "promote", + candidate_id, + "--env", + "local", + "--window", + "7d", + "--reason", + "try expensive candidate", + ], + ) + assert res.exit_code != 0 + assert "Policy: FAIL" in res.output + assert "Promotion blocked by policy" in res.output + assert "candidate cost_per_run_usd" in res.output + + storage = Storage(load_config().db_path) + storage.migrate() + assert storage.get_promoted_release_id("agent_support", "local") == baseline_id + + res = runner.invoke(cli, ["release", "history", "--agent", "agent_support", "--env", "local"]) + assert res.exit_code == 0 + assert "try expensive candidate" in res.output + assert "FAIL" in res.output + assert candidate_id in res.output + + +def test_passing_second_promotion_replaces_current_release(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + assert runner.invoke(cli, ["init"]).exit_code == 0 + policy = write_policy(tmp_path, max_cost_per_run_usd=10.0) + assert runner.invoke(cli, ["policy", "set", str(policy)]).exit_code == 0 + pricing = write_pricing(tmp_path, provider="openai", pricing_version="openai-2026-04-30") + assert runner.invoke(cli, ["pricing", "import", str(pricing)]).exit_code == 0 + + baseline_dir = write_release( + tmp_path, + agent_id="agent_support", + version="1", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + candidate_dir = write_release( + tmp_path, + agent_id="agent_support", + version="2", + pricing_provider="openai", + pricing_version="openai-2026-04-30", + ) + baseline_id = runner.invoke(cli, ["release", "register", str(baseline_dir)]).output.strip() + candidate_id = runner.invoke(cli, ["release", "register", str(candidate_dir)]).output.strip() + + now = datetime.now(tz=timezone.utc) + baseline_events = write_events(tmp_path, release_id=baseline_id, agent_id="agent_support", n=5, ts=now) + candidate_events = write_events(tmp_path, release_id=candidate_id, agent_id="agent_support", n=5, ts=now) + assert runner.invoke(cli, ["runs", "ingest", str(baseline_events)]).exit_code == 0 + assert runner.invoke(cli, ["runs", "ingest", str(candidate_events)]).exit_code == 0 + + assert ( + runner.invoke( + cli, + [ + "release", + "promote", + baseline_id, + "--env", + "local", + "--window", + "7d", + "--reason", + "baseline", + ], + ).exit_code + == 0 + ) + res = runner.invoke( + cli, + [ + "release", + "promote", + candidate_id, + "--env", + "local", + "--window", + "7d", + "--reason", + "safe candidate", + ], + ) + assert res.exit_code == 0 + assert f"Promoted {candidate_id}" in res.output + + storage = Storage(load_config().db_path) + storage.migrate() + assert storage.get_promoted_release_id("agent_support", "local") == candidate_id + From 92cc7ab9a2bf2bd4bf091516262dacf430dcc60a Mon Sep 17 00:00:00 2001 From: zendaya Date: Fri, 1 May 2026 16:08:41 -0700 Subject: [PATCH 2/6] adds pypi fixes --- .cursorrules | 2 +- .github/workflows/ci.yml | 42 +- .github/workflows/release-pypi.yml | 89 +++ .python-version | 1 + AGENTS.md | 20 +- CHANGELOG.md | 15 +- CLAUDE.md | 15 +- CONTRIBUTING.md | 20 +- DEVELOPMENT.md | 52 +- README.md | 24 +- RELEASE_NOTES.md | 13 +- VERSIONING.md | 4 + pyproject.toml | 13 +- tests/test_version_consistency.py | 25 + uv.lock | 963 +++++++++++++++++++++++++++++ 15 files changed, 1247 insertions(+), 51 deletions(-) create mode 100644 .github/workflows/release-pypi.yml create mode 100644 .python-version create mode 100644 tests/test_version_consistency.py create mode 100644 uv.lock diff --git a/.cursorrules b/.cursorrules index e654732..c09eb0e 100644 --- a/.cursorrules +++ b/.cursorrules @@ -2,6 +2,6 @@ Read **`AGENTS.md`** for mission, non-goals, public contracts, engineering rules, verification, and docs policy. **`CLAUDE.md`** is a short index for Claude Code / Cursor. -Quick verify: `python -m ruff check src tests`, `python -m pytest`, `python scripts/quickstart_smoke.py` (on Windows, `py -3` if needed). +Quick verify (uv): `uv sync --frozen --extra dev`, then `uv run python -m ruff check src tests`, `uv run python -m pytest`, `uv run python scripts/quickstart_smoke.py` (pip/venv equivalents in `DEVELOPMENT.md`; on Windows, `py -3` if needed). Normative v1 direction: https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md · backlog: https://github.com/flightdeckdev/flightdeck/blob/main/docs/v1-next-steps.md · CLI: https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66b9f4f..b603b62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,69 +11,71 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.11", "3.12", "3.13", "3.14"] + python-version: ["3.14"] steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + - name: Set up uv + uses: astral-sh/setup-uv@v5 with: + enable-cache: true python-version: ${{ matrix.python-version }} - - name: Install - run: python -m pip install -e ".[dev]" + - name: Sync dependencies + run: uv sync --frozen --extra dev - name: Lint - run: python -m ruff check src tests + run: uv run python -m ruff check src tests - name: Test - run: python -m pytest + run: uv run python -m pytest - name: JSON Schemas drift check run: | - python scripts/generate_schemas.py + uv run python scripts/generate_schemas.py git diff --exit-code schemas/ - name: Quickstart smoke (cross-platform) - run: python scripts/quickstart_smoke.py + run: uv run python scripts/quickstart_smoke.py - name: CLI smoke - run: flightdeck --help + run: uv run flightdeck --help test-windows: runs-on: windows-latest strategy: fail-fast: false matrix: - python-version: ["3.11", "3.12", "3.13", "3.14"] + python-version: ["3.14"] steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + - name: Set up uv + uses: astral-sh/setup-uv@v5 with: + enable-cache: true python-version: ${{ matrix.python-version }} - - name: Install - run: python -m pip install -e ".[dev]" + - name: Sync dependencies + run: uv sync --frozen --extra dev - name: Lint - run: python -m ruff check src tests + run: uv run python -m ruff check src tests - name: Test - run: python -m pytest + run: uv run python -m pytest - name: JSON Schemas drift check run: | - python scripts/generate_schemas.py + uv run python scripts/generate_schemas.py git diff --exit-code schemas/ - name: Quickstart smoke (cross-platform) - run: python scripts/quickstart_smoke.py + run: uv run python scripts/quickstart_smoke.py - name: CLI smoke - run: flightdeck --help + run: uv run flightdeck --help diff --git a/.github/workflows/release-pypi.yml b/.github/workflows/release-pypi.yml new file mode 100644 index 0000000..88f6a5c --- /dev/null +++ b/.github/workflows/release-pypi.yml @@ -0,0 +1,89 @@ +# Publish sdist + wheel to PyPI when a SemVer tag is pushed (e.g. v1.0.2). +# Configure "trusted publishing" on PyPI for this workflow + repository + optional GitHub environment. +# https://docs.pypi.org/trusted-publishers/ + +name: Release (PyPI) + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + +jobs: + publish: + name: Build, verify, publish + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/flightdeck-ai/ + permissions: + contents: write + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + python-version-file: .python-version + + - name: Sync locked dev environment + run: uv sync --frozen --extra dev + + - name: Verify tag matches declared package versions + shell: bash + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Unexpected tag format: $TAG (expected vMAJOR.MINOR.PATCH)" + exit 1 + fi + uv run python <<'PY' + import os + import pathlib + import re + import tomllib + + tag = os.environ["TAG"] + expected = tag.removeprefix("v") + data = tomllib.loads(pathlib.Path("pyproject.toml").read_text(encoding="utf-8")) + pyproject = data["project"]["version"] + init = pathlib.Path("src/flightdeck/__init__.py").read_text(encoding="utf-8") + m = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', init) + if not m: + raise SystemExit("Could not parse __version__ from src/flightdeck/__init__.py") + module = m.group(1) + if pyproject != expected: + raise SystemExit(f"Tag {tag} expects {expected} but pyproject.toml has {pyproject}") + if module != expected: + raise SystemExit(f"Tag {tag} expects {expected} but __init__.py has {module}") + PY + + - name: Lint + run: uv run python -m ruff check src tests + + - name: Test + run: uv run python -m pytest + + - name: JSON Schemas drift check + run: | + uv run python scripts/generate_schemas.py + git diff --exit-code schemas/ + + - name: Build distributions + run: uv build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + attestations: true + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/AGENTS.md b/AGENTS.md index a58e8f1..69d6788 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,17 +61,27 @@ Do a short review pass for: ## Verification -Run before finalizing changes: +Recommended (**[uv](https://docs.astral.sh/uv/)** — see **`DEVELOPMENT.md`**): ```bash -python -m ruff check src tests -python -m pytest -python scripts/quickstart_smoke.py +uv sync --frozen --extra dev +uv run python -m ruff check src tests +uv run python -m pytest +uv run python scripts/quickstart_smoke.py ``` +After editing Pydantic models, regenerate schemas and ensure a clean diff: + +```bash +uv run python scripts/generate_schemas.py +git diff --exit-code schemas/ +``` + +Fallback (activated **venv** or global tools): the same steps with **`python -m …`** / **`python scripts/…`** as in **`DEVELOPMENT.md`**. + On **Windows**, use `py -3` in place of `python` if that is how your environment is set up. If pytest temp dirs fail with permissions, see **`DEVELOPMENT.md`** / **`tests/conftest.py`**. -**CI bar** (same commands locally before a PR): **`ruff check`**, **`pytest`**, **`scripts/generate_schemas.py`** + no **`schemas/`** diff, **`scripts/quickstart_smoke.py`**, **`flightdeck --help`**. +**CI bar** (mirrors **`.github/workflows/ci.yml`** on **CPython 3.14**): **`uv sync --frozen --extra dev`**, **`uv run python -m ruff check src tests`**, **`uv run python -m pytest`**, **`uv run python scripts/generate_schemas.py`** + no **`schemas/`** diff, **`uv run python scripts/quickstart_smoke.py`**, **`uv run flightdeck --help`**. Use a repo-local temp directory if the OS temp directory is restricted. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c4f18d..88c4ab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,18 @@ This project follows [Semantic Versioning](https://semver.org/). From **v1.0.0** ## Unreleased -Nothing yet. +### Added + +- **`uv.lock`** and **[uv](https://docs.astral.sh/uv/)**-based workflow: **`uv sync --extra dev`** / **`uv sync --frozen --extra dev`** for reproducible installs; **`uv run …`** for commands (see **`DEVELOPMENT.md`**). +- **CI:** **`astral-sh/setup-uv`** with **`uv sync --frozen --extra dev`** and **`uv run python -m …`** (avoids **`uv run pytest`** path quirks with **`from tests.…`** imports). +- **`.github/workflows/release-pypi.yml`:** on push of **`vMAJOR.MINOR.PATCH`**, verify tag matches **`pyproject.toml`** and **`src/flightdeck/__init__.py`**, run **ruff** / **pytest** / schema drift, **`uv build`**, publish to **PyPI** via **OIDC** trusted publishing (**publish attestations**), and create a **GitHub Release** with **`dist/*`** assets (**`softprops/action-gh-release`**). +- **`tests/test_version_consistency.py`:** assert **`pyproject.toml`** **`version`** matches **`flightdeck.__version__`** (same invariant as the release workflow). + +### Changed + +- **`pyproject.toml` `[project] name`:** **`flightdeck-ai`** to match the **PyPI** trusted-publisher project; install with **`pip install flightdeck-ai`** / **`uv add flightdeck-ai`** (CLI remains **`flightdeck`**, imports **`flightdeck.*`**). +- **Contributor docs** (**`README.md`**, **`DEVELOPMENT.md`**, **`CONTRIBUTING.md`**, **`AGENTS.md`**, **`CLAUDE.md`**, **`.cursorrules`**): prefer **uv**; keep **pip** / **`python -m venv`** as fallback. +- **Python:** **`requires-python >=3.14,<3.15`**, **`.python-version`**, PyPI classifiers, **Ruff** `target-version`, **`uv.lock`**, and **CI** matrices now target **CPython 3.14** only (replacing broader **3.11–3.14** testing). ## 1.0.1 - 2026-05-01 @@ -19,7 +30,7 @@ Nothing yet. - **Slim distribution:** this repository omits the full in-tree **`docs/`** tree, org mirror scripts, and **`verify-repo-standards`** wrappers. Narrative docs and maintainer runbooks live on **[github.com/flightdeckdev/flightdeck](https://github.com/flightdeckdev/flightdeck)**; in-repo links now point there where applicable. - **`pyproject.toml`:** OpenTelemetry packages are **optional** only (**`telemetry`** / **`all`** extras); the default install matches the **1.0.0** dependency story (core does not import OpenTelemetry). - **`.pre-commit-config.yaml`:** **ruff** replaces **black** / **isort**; **`ruff-pre-commit`** pinned to **v0.15.12** to match **`dev`** (**`ruff==0.15.12`**). -- **CI:** Python **3.13** and **3.14** added to the Ubuntu and Windows matrices. +- **CI:** Python **3.13** and **3.14** added to the Ubuntu and Windows matrices (superseded by **3.14**-only policy; see **Unreleased**). - **`pyproject.toml`:** default **`pytest --basetemp=.tmp/pytest`** so local runs avoid Windows **`PermissionError`** on **`%TEMP%\pytest-of-*`**. - **`pre-commit-hooks`:** bumped to **v5.0.0**. diff --git a/CLAUDE.md b/CLAUDE.md index 5c56eba..d95a696 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,14 +20,19 @@ Canonical repository (full **`docs/`** tree and org workflows): **[github.com/fl ## Verify before you finish +With **uv** (recommended): + ```bash -python -m ruff check src tests -python -m pytest -python scripts/quickstart_smoke.py +uv sync --frozen --extra dev +uv run python -m ruff check src tests +uv run python -m pytest +uv run python scripts/quickstart_smoke.py ``` -**Windows:** if `python` is not on `PATH`, use `py -3` for the same commands. +With **pip** + venv: use **`python -m …`** equivalents in **`DEVELOPMENT.md`**. + +**Windows:** if `python` is not on `PATH`, use `py -3` for the same commands (or install **uv** and use **`uv run`**). ## Repo shape -Python package under `src/flightdeck/`. Tests in `tests/`. Examples in `examples/quickstart/`. JSON Schemas under `schemas/` (regenerate with `python scripts/generate_schemas.py` when models change). +Python package under `src/flightdeck/`. Tests in `tests/`. Examples in `examples/quickstart/`. JSON Schemas under `schemas/` (regenerate with **`uv run python scripts/generate_schemas.py`** when models change). After **`pyproject.toml`** dependency edits, run **`uv lock`** and commit **`uv.lock`**. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f7bb7e..aa1a394 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,14 @@ Human and AI contributors: follow **[AGENTS.md](AGENTS.md)** (full rules). For a ## Local Setup +Recommended (**[uv](https://docs.astral.sh/uv/)** — see **`DEVELOPMENT.md`**): + +```bash +uv sync --extra dev +``` + +Fallback (**pip**): + ```bash python -m venv .venv python -m pip install -e ".[dev]" @@ -15,13 +23,23 @@ python -m pip install -e ".[dev]" ## Verify +With **uv** (matches CI): + +```bash +uv run python -m ruff check src tests +uv run python -m pytest +uv run python scripts/quickstart_smoke.py +``` + +With an activated **venv**: + ```bash python -m ruff check src tests python -m pytest python scripts/quickstart_smoke.py ``` -Use the same commands as **CI** (see **`AGENTS.md`**) before opening a PR. +If you change **`pyproject.toml`** dependencies, run **`uv lock`** and commit **`uv.lock`**. Use the same checks as **CI** (see **`AGENTS.md`**) before opening a PR. ## Private files and pushing to GitHub diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ddc1089..434e11c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -2,18 +2,47 @@ ## Requirements -- Python **3.11+** (CI runs **3.11** through **3.14** on Ubuntu and Windows) +- **CPython 3.14.x** only (`requires-python` in **`pyproject.toml`**; **`.python-version`** pins **3.14** for **uv**). CI runs **3.14** on Ubuntu and Windows. - Git +- **[uv](https://docs.astral.sh/uv/)** (recommended): single tool for venvs, installs, and **`uv run`** ([installation](https://docs.astral.sh/uv/getting-started/installation/)). On Windows you can use `py -3 -m pip install uv` if you do not use the standalone installer. -## Setup +**Note:** search hits like **`flightdeck-1.0.1.dist-info`** under **`.venv/`** are normal install metadata (**distribution name + version**), not references to another repository. + +## Setup (uv — recommended) + +From the repository root: + +```bash +uv sync --extra dev +``` + +This creates **`.venv/`** (gitignored), installs **`flightdeck`** editable plus **pytest** and **ruff**, and pins versions from **`uv.lock`**. + +Optional extras (telemetry, SDK helpers): e.g. **`uv sync --extra dev --extra telemetry`**. + +## Setup (pip — fallback) ```bash python -m venv .venv +# Windows: .venv\Scripts\activate +# Unix: source .venv/bin/activate python -m pip install -e ".[dev]" ``` ## Verify +With **uv**: + +```bash +uv run python -m ruff check src tests +uv run python -m pytest +uv run flightdeck --help +uv run flightdeck doctor +uv run python scripts/quickstart_smoke.py +``` + +With an **activated venv** (pip or after `uv sync`): + ```bash python -m ruff check src tests python -m pytest @@ -24,8 +53,23 @@ python scripts/quickstart_smoke.py Full command flags and exit codes: [docs/cli.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/cli.md). Cross-platform quickstart parity: **`scripts/quickstart_smoke.py`** (also run in CI). +**Lockfile:** when you change **`pyproject.toml`** dependencies or extras, run **`uv lock`** and commit **`uv.lock`** so CI stays **`--frozen`**-reproducible. + Before pushing to an **org** remote, follow the maintainer checklist in [docs/github-organization.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/github-organization.md) and [docs/research-workflow.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/research-workflow.md) ([git remotes](https://github.com/flightdeckdev/flightdeck/blob/main/docs/git-remotes.md): `origin` = personal research, `org` = flightdeckdev). +## PyPI release (maintainers) + +Merging to **`main` does not publish packages** — PyPI uploads are **tag-driven** (workflow **`.github/workflows/release-pypi.yml`**). The **PyPI project** is **`flightdeck-ai`** (`pip install flightdeck-ai`); the **`flightdeck`** CLI and **`import flightdeck`** layout are unchanged. + +1. **PyPI:** add a **trusted publisher** for **[github.com/flightdeckdev/flightdeck](https://github.com/flightdeckdev/flightdeck)** — workflow **`release-pypi.yml`**. If PyPI offers **Environment name: (Any)**, you can still use a GitHub **Environment** named **`pypi`** for approval gates; otherwise match whatever you register on PyPI ([trusted publishers](https://docs.pypi.org/trusted-publishers/)). +2. **GitHub:** Settings → **Environments** → create **`pypi`** (optional: required reviewers / wait timer before OIDC publish). +3. Bump **`version`** in **`pyproject.toml`** and **`src/flightdeck/__init__.py`**, update **`CHANGELOG.md`**, merge to **`main`**. +4. **`git tag vX.Y.Z`** (must match **`pyproject.toml`** exactly, e.g. **`v1.0.2`**) then **`git push origin vX.Y.Z`**. + +The workflow runs **ruff**, **pytest**, schema drift, **`uv build`**, publishes **sdist + wheel** to **PyPI** via **OIDC** (no long-lived API token in repo secrets), enables **publish attestations**, and creates a **GitHub Release** with generated notes and **`dist/*`** assets. + +If **PyPI** rejects **attestations** for your project, set **`attestations: false`** on **`pypa/gh-action-pypi-publish`** in **`.github/workflows/release-pypi.yml`** until the registry side is sorted. + ## Local Demo ```bash @@ -74,7 +118,7 @@ dirs at the repo-local `.tmp/` directory: $env:TEMP = (Resolve-Path .tmp).Path $env:TMP = $env:TEMP $env:TMPDIR = $env:TEMP -.\.venv\Scripts\python.exe -m pytest +uv run python -m pytest ``` By default, `tests/conftest.py` also redirects `TEMP`/`TMP` into `.tmp/` during pytest on Windows. Set @@ -86,3 +130,5 @@ virtual environment's Python executable directly: ```bash .venv/bin/python -m pytest ``` + +Use **`uv run python -m pytest`** from the repo root so imports like **`from tests.test_spine import …`** resolve the same way as in CI. diff --git a/README.md b/README.md index 34c54d5..ef6f50c 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Not implemented yet: - hosted control plane - automated traffic routing - tool-cost pricing -- OpenTelemetry import/export mapping (optional **`pip install 'flightdeck[telemetry]'`** pulls deps for future work) +- OpenTelemetry import/export mapping (optional **`uv sync --extra telemetry`** or **`pip install 'flightdeck-ai[telemetry]'`** for future work) Shipped locally: @@ -49,6 +49,15 @@ Shipped locally: ## Quickstart +Install **[uv](https://docs.astral.sh/uv/getting-started/installation/)**, then from the repo root: + +```bash +uv sync --extra dev +uv run flightdeck --help +``` + +Or with **pip** and a venv: + ```bash python -m venv .venv python -m pip install -e ".[dev]" @@ -58,9 +67,11 @@ flightdeck --help Run the cross-platform quickstart smoke (same as CI): ```bash -python scripts/quickstart_smoke.py +uv run python scripts/quickstart_smoke.py ``` +(or **`python scripts/quickstart_smoke.py`** inside an activated venv) + Or use the bash wrapper (Git Bash / WSL on Windows): ```bash @@ -90,7 +101,7 @@ flightdeck release history --agent agent_support --env local ``` The static event files in `examples/quickstart` use placeholder release IDs so the repo can ship stable examples. -Substitute them before ingestion, or run **`python scripts/quickstart_smoke.py`** (any OS) or **`./scripts/smoke.sh`** from Git Bash/WSL on Windows. +Substitute them before ingestion, or run **`uv run python scripts/quickstart_smoke.py`** / **`python scripts/quickstart_smoke.py`** (venv) or **`./scripts/smoke.sh`** from Git Bash/WSL on Windows. ## Documentation @@ -117,11 +128,12 @@ This tree stays small; narrative docs live on **[github.com/flightdeckdev/flight ## Development ```bash -python -m ruff check src tests -python -m pytest +uv sync --frozen --extra dev +uv run python -m ruff check src tests +uv run python -m pytest ``` -See [DEVELOPMENT.md](DEVELOPMENT.md) for setup, verification, and troubleshooting. +See [DEVELOPMENT.md](DEVELOPMENT.md) for **uv** and **pip** setup, verification, troubleshooting, and **PyPI releases** (tag-driven; not on merge to `main`). ## License diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 0174129..bbe3a1d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -6,7 +6,16 @@ Narrative docs (including the CLI reference) are maintained on **[github.com/fli ## v1.0.1 — distribution and developer tooling -Patch release (see **[CHANGELOG.md](CHANGELOG.md)**): canonical **`main`** URLs for narrative docs in slim clones, optional OpenTelemetry in extras only, CI coverage for **Python 3.13–3.14**, repo-local **pytest** basetemp on Windows, **ruff** pinned consistently with **pre-commit**, **`.gitattributes`** LF for the golden bundle fixture, and removal of a test that depended on unpublished export scripts. **Public CLI / schema / HTTP contracts** are unchanged from **v1.0.0**. +Patch release (see **[CHANGELOG.md](CHANGELOG.md)**): canonical **`main`** URLs for narrative docs in slim clones, optional OpenTelemetry in extras only, CI on **CPython 3.14** (Ubuntu and Windows), repo-local **pytest** basetemp on Windows, **ruff** pinned consistently with **pre-commit**, **`.gitattributes`** LF for the golden bundle fixture, and removal of a test that depended on unpublished export scripts. **Public CLI / schema / HTTP contracts** are unchanged from **v1.0.0**. + +## PyPI and GitHub releases + +- **Not automatic on merge:** publishing runs when a **SemVer tag** matching **`vMAJOR.MINOR.PATCH`** is pushed (see **`.github/workflows/release-pypi.yml`**). +- **PyPI project:** **`flightdeck-ai`** (matches **`[project] name`** in **`pyproject.toml`**). **Trusted publishing** (OIDC) — no **`PYPI_API_TOKEN`** in repo secrets; register workflow **`release-pypi.yml`** on the project. If PyPI shows **Environment name: (Any)**, you do not need to match a specific string there; the workflow still uses GitHub **Environment** **`pypi`** for optional approval gates. +- **Checks before upload:** same bar as CI (**ruff**, **pytest**, schema drift) plus a **tag ↔ `pyproject.toml` version** match. +- **GitHub:** the workflow creates a **Release** for the tag with **generated notes** and attaches **`dist/*`**. + +Details: **`DEVELOPMENT.md`** (PyPI release section). ## v1.0.0 — stable public contracts @@ -19,7 +28,7 @@ Patch release (see **[CHANGELOG.md](CHANGELOG.md)**): canonical **`main`** URLs The product remains **local-first**; hosted control plane, OTel mapping, and related items stay on **`ROADMAP.md`**. -Optional OTEL libraries (not used by core today): **`pip install 'flightdeck[telemetry]'`**. +Optional OTEL libraries (not used by core today): **`pip install 'flightdeck-ai[telemetry]'`**. ## Python SDK diff --git a/VERSIONING.md b/VERSIONING.md index 5316d9a..51f40c3 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -32,3 +32,7 @@ are still the mechanism for evolving the on-disk schema safely. ## Stable contracts (v1.0.0) The **v1.0.0** freeze is summarized in **[RELEASE_NOTES.md](RELEASE_NOTES.md)** and **[docs/spec-v1-forward.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/spec-v1-forward.md)**; milestone status lives in **[docs/v1-next-steps.md](https://github.com/flightdeckdev/flightdeck/blob/main/docs/v1-next-steps.md)**. + +## PyPI packages + +The **PyPI** distribution is **`flightdeck-ai`** (same **SemVer** as **`pyproject.toml`**). The CLI command remains **`flightdeck`**; Python imports remain **`flightdeck.*`**. Publishing is **tag-driven** (push **`v*.*.*`**) via **`.github/workflows/release-pypi.yml`** — see **`DEVELOPMENT.md`** and **`RELEASE_NOTES.md`** (PyPI / GitHub releases). diff --git a/pyproject.toml b/pyproject.toml index 46d6b68..e3d30a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,12 +3,12 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "flightdeck" +name = "flightdeck-ai" version = "1.0.1" description = "AI Release Governance for production agents." readme = "README.md" license = "Apache-2.0" -requires-python = ">=3.11" +requires-python = ">=3.14,<3.15" authors = [{ name = "FlightDeck" }] keywords = ["ai", "agents", "control-plane", "finops", "deployment", "policy", "operations"] classifiers = [ @@ -16,9 +16,6 @@ classifiers = [ "Intended Audience :: Developers", "Topic :: System :: Monitoring", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] dependencies = [ @@ -65,8 +62,12 @@ flightdeck = "flightdeck.cli.main:cli" [tool.hatch.build.targets.wheel] packages = ["src/flightdeck"] +[tool.uv] +# Contributor installs: `uv sync --extra dev` (see DEVELOPMENT.md). After changing +# dependencies or optional extras, run `uv lock` and commit `uv.lock`. + [tool.ruff] -target-version = "py311" +target-version = "py314" line-length = 100 [tool.pytest.ini_options] diff --git a/tests/test_version_consistency.py b/tests/test_version_consistency.py new file mode 100644 index 0000000..6bac5a4 --- /dev/null +++ b/tests/test_version_consistency.py @@ -0,0 +1,25 @@ +"""Keep pyproject.toml and flightdeck.__version__ aligned (release workflow assumes this).""" + +from __future__ import annotations + +import pathlib +import re + +import tomllib + +from flightdeck import __version__ + + +def test_pyproject_version_matches_flightdeck_init() -> None: + root = pathlib.Path(__file__).resolve().parents[1] + data = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) + declared = data["project"]["version"] + assert declared == __version__ + + +def test_init_version_is_assignable_string() -> None: + root = pathlib.Path(__file__).resolve().parents[1] + text = (root / "src" / "flightdeck" / "__init__.py").read_text(encoding="utf-8") + m = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', text) + assert m is not None + assert m.group(1) == __version__ diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..1839773 --- /dev/null +++ b/uv.lock @@ -0,0 +1,963 @@ +version = 1 +revision = 3 +requires-python = "==3.14.*" + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +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, 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, 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, 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, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anthropic" +version = "0.97.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/93/f66ea8bfe39f2e6bb9da8e27fa5457ad2520e8f7612dfc547b17fad55c4d/anthropic-0.97.0.tar.gz", hash = "sha256:021e79fd8e21e90ad94dc5ba2bbbd8b1599f424f5b1fab6c06204009cab764be", size = 669502, upload-time = "2026-04-23T20:52:34.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/b6/8e851369fa661ad0fef2ae6266bf3b7d52b78ccf011720058f4adaca59e2/anthropic-0.97.0-py3-none-any.whl", hash = "sha256:8a1a472dfabcfc0c52ff6a3eecf724ac7e07107a2f6e2367be55ceb42f5d5613", size = 662126, upload-time = "2026-04-23T20:52:32.377Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[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, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { 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]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "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]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, +] + +[[package]] +name = "flightdeck-ai" +version = "1.0.1" +source = { editable = "." } +dependencies = [ + { name = "aiosqlite" }, + { name = "click" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "sqlalchemy" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +all = [ + { name = "anthropic" }, + { name = "openai" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-sdk" }, +] +anthropic = [ + { name = "anthropic" }, +] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] +openai = [ + { name = "openai" }, +] +telemetry = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-sdk" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiosqlite", specifier = ">=0.19.0" }, + { name = "anthropic", marker = "extra == 'all'", specifier = ">=0.20" }, + { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.20" }, + { name = "click", specifier = ">=8.0" }, + { name = "fastapi", specifier = ">=0.100.0" }, + { name = "httpx", specifier = ">=0.24.0" }, + { name = "openai", marker = "extra == 'all'", specifier = ">=1.0" }, + { name = "openai", marker = "extra == 'openai'", specifier = ">=1.0" }, + { name = "opentelemetry-api", marker = "extra == 'all'", specifier = ">=1.20.0" }, + { name = "opentelemetry-api", marker = "extra == 'telemetry'", specifier = ">=1.20.0" }, + { name = "opentelemetry-exporter-otlp", marker = "extra == 'all'", specifier = ">=1.20.0" }, + { name = "opentelemetry-exporter-otlp", marker = "extra == 'telemetry'", specifier = ">=1.20.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'all'", specifier = ">=1.20.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'telemetry'", specifier = ">=1.20.0" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "rich", specifier = ">=13.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.12" }, + { name = "sqlalchemy", specifier = ">=2.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.20.0" }, +] +provides-extras = ["openai", "anthropic", "telemetry", "all", "dev"] + +[[package]] +name = "googleapis-common-protos" +version = "1.74.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/18/a746c8344152d368a5aac738d4c857012f2c5d1fd2eac7e17b647a7861bd/googleapis_common_protos-1.74.0.tar.gz", hash = "sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1", size = 151254, upload-time = "2026-04-02T21:23:26.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743, upload-time = "2026-04-02T21:22:49.108Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" }, + { url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" }, + { url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" }, + { url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" }, + { url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" }, + { url = "https://files.pythonhosted.org/packages/4e/62/1c498375cee177b55d980c1db319f26470e5309e54698c8f8fc06c0fd539/greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", size = 236862, upload-time = "2026-04-27T12:23:24.957Z" }, + { url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" }, + { url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" }, + { url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" }, + { url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" }, +] + +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jiter" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/1e/354ed92461b165bd581f9ef5150971a572c873ec3b68a916d5aa91da3cc2/jiter-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140", size = 315277, upload-time = "2026-04-10T14:27:18.109Z" }, + { url = "https://files.pythonhosted.org/packages/a6/95/8c7c7028aa8636ac21b7a55faef3e34215e6ed0cbf5ae58258427f621aa3/jiter-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9", size = 315923, upload-time = "2026-04-10T14:27:19.603Z" }, + { url = "https://files.pythonhosted.org/packages/47/40/e2a852a44c4a089f2681a16611b7ce113224a80fd8504c46d78491b47220/jiter-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615", size = 344943, upload-time = "2026-04-10T14:27:21.262Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1f/670f92adee1e9895eac41e8a4d623b6da68c4d46249d8b556b60b63f949e/jiter-0.14.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850", size = 369725, upload-time = "2026-04-10T14:27:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/01/2f/541c9ba567d05de1c4874a0f8f8c5e3fd78e2b874266623da9a775cf46e0/jiter-0.14.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9", size = 461210, upload-time = "2026-04-10T14:27:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/c31cbec09627e0d5de7aeaec7690dba03e090caa808fefd8133137cf45bc/jiter-0.14.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994", size = 380002, upload-time = "2026-04-10T14:27:26.155Z" }, + { url = "https://files.pythonhosted.org/packages/50/02/3c05c1666c41904a2f607475a73e7a4763d1cbde2d18229c4f85b22dc253/jiter-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa", size = 354678, upload-time = "2026-04-10T14:27:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/7d/97/e15b33545c2b13518f560d695f974b9891b311641bdcf178d63177e8801e/jiter-0.14.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5", size = 358920, upload-time = "2026-04-10T14:27:29.256Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d2/8b1461def6b96ba44530df20d07ef7a1c7da22f3f9bf1727e2d611077bf1/jiter-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928", size = 394512, upload-time = "2026-04-10T14:27:31.344Z" }, + { url = "https://files.pythonhosted.org/packages/e3/88/837566dd6ed6e452e8d3205355afd484ce44b2533edfa4ed73a298ea893e/jiter-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28", size = 521120, upload-time = "2026-04-10T14:27:33.299Z" }, + { url = "https://files.pythonhosted.org/packages/89/6b/b00b45c4d1b4c031777fe161d620b755b5b02cdade1e316dcb46e4471d63/jiter-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de", size = 553668, upload-time = "2026-04-10T14:27:34.868Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d8/6fe5b42011d19397433d345716eac16728ac241862a2aac9c91923c7509a/jiter-0.14.0-cp314-cp314-win32.whl", hash = "sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc", size = 207001, upload-time = "2026-04-10T14:27:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/e5/43/5c2e08da1efad5e410f0eaaabeadd954812612c33fbbd8fd5328b489139d/jiter-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02", size = 202187, upload-time = "2026-04-10T14:27:38Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1f/6e39ac0b4cdfa23e606af5b245df5f9adaa76f35e0c5096790da430ca506/jiter-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611", size = 192257, upload-time = "2026-04-10T14:27:39.504Z" }, + { url = "https://files.pythonhosted.org/packages/05/57/7dbc0ffbbb5176a27e3518716608aa464aee2e2887dc938f0b900a120449/jiter-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b", size = 323441, upload-time = "2026-04-10T14:27:41.039Z" }, + { url = "https://files.pythonhosted.org/packages/83/6e/7b3314398d8983f06b557aa21b670511ec72d3b79a68ee5e4d9bff972286/jiter-0.14.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a", size = 348109, upload-time = "2026-04-10T14:27:42.552Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4f/8dc674bcd7db6dba566de73c08c763c337058baff1dbeb34567045b27cdc/jiter-0.14.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a", size = 368328, upload-time = "2026-04-10T14:27:44.574Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/188e09a1f20906f98bbdec44ed820e19f4e8eb8aff88b9d1a5a497587ff3/jiter-0.14.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b", size = 463301, upload-time = "2026-04-10T14:27:46.717Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f0/19046ef965ed8f349e8554775bb12ff4352f443fbe12b95d31f575891256/jiter-0.14.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746", size = 378891, upload-time = "2026-04-10T14:27:48.32Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c3/da43bd8431ee175695777ee78cf0e93eacbb47393ff493f18c45231b427d/jiter-0.14.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310", size = 360749, upload-time = "2026-04-10T14:27:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/72/26/e054771be889707c6161dbdec9c23d33a9ec70945395d70f07cfea1e9a6f/jiter-0.14.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4", size = 358526, upload-time = "2026-04-10T14:27:51.504Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0f/7bea65ea2a6d91f2bf989ff11a18136644392bf2b0497a1fa50934c30a9c/jiter-0.14.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2", size = 393926, upload-time = "2026-04-10T14:27:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/b1ff7d70deef61ac0b7c6c2f12d2ace950cdeecb4fdc94500a0926802857/jiter-0.14.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560", size = 521052, upload-time = "2026-04-10T14:27:55.058Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7b/3b0649983cbaf15eda26a414b5b1982e910c67bd6f7b1b490f3cfc76896a/jiter-0.14.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06", size = 553716, upload-time = "2026-04-10T14:27:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[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, 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, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "openai" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/ee/d056c82f63c05f06baac0cffb4a90952d8274f90c49dfe244f20497b9bbd/openai-2.33.0.tar.gz", hash = "sha256:f850c435e2a4685bba3295bd54912dd26315d9c1b7733068186134d6e0599f9a", size = 693254, upload-time = "2026-04-28T14:04:42.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/32/37734d769bc8b42e4938785313cc05aade6cb0fa72479d3220a0d61a4e78/openai-2.33.0-py3-none-any.whl", hash = "sha256:03ac37d70e8c9e3a8124214e3afa785e2cbc12e627fbd98177a086ef2fd87ad5", size = 1162695, upload-time = "2026-04-28T14:04:40.482Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/fc/b7564cbef36601aef0d6c9bc01f7badb64be8e862c2e1c3c5c3b43b53e4f/opentelemetry_api-1.41.1.tar.gz", hash = "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621", size = 71416, upload-time = "2026-04-24T13:15:38.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/59/3e7118ed140f76b0982ba4321bdaed1997a0473f9720de2d10788a577033/opentelemetry_api-1.41.1-py3-none-any.whl", hash = "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f", size = 69007, upload-time = "2026-04-24T13:15:15.662Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/84/d55baf8e1a222f40282956083e67de9fa92d5fa451108df4839505fa2a24/opentelemetry_exporter_otlp-1.41.1.tar.gz", hash = "sha256:299a2f0541ca175df186f5ac58fd5db177ba1e9b72b0826049062f750d55b47f", size = 6152, upload-time = "2026-04-24T13:15:40.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/d5/ea4aa7dfc458fd537bd9519ea0e7226eef2a6212dfe952694984167daaba/opentelemetry_exporter_otlp-1.41.1-py3-none-any.whl", hash = "sha256:db276c5a80c02b063994e80950d00ca1bfddcf6520f608335b7dc2db0c0eb9c6", size = 7025, upload-time = "2026-04-24T13:15:17.839Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/fa/f9e3bd3c4d692b3ce9a2880a167d1f79681a1bea11f00d5bf76adc03e6ea/opentelemetry_exporter_otlp_proto_common-1.41.1.tar.gz", hash = "sha256:0e253156ea9c36b0bd3d2440c5c9ba7dd1f3fb64ba7a08fc85fbac536b56e1fb", size = 20409, upload-time = "2026-04-24T13:15:40.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/48/bce76d3ea772b609757e9bc844e02ab408a6446609bf74fb562062ba6b71/opentelemetry_exporter_otlp_proto_common-1.41.1-py3-none-any.whl", hash = "sha256:10da74dad6a49344b9b7b21b6182e3060373a235fde1528616d5f01f92e66aa9", size = 18366, upload-time = "2026-04-24T13:15:18.917Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/9b/e4503060b8695579dbaad187dc8cef4554188de68748c88060599b77489e/opentelemetry_exporter_otlp_proto_grpc-1.41.1.tar.gz", hash = "sha256:b05df8fa1333dc9a3fda36b676b96b5095ab6016d3f0c3296d430d629ba1443b", size = 25755, upload-time = "2026-04-24T13:15:41.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/f2/c54f33c92443d087703e57e52e55f22f111373a5c4c4aa349ea60efe512e/opentelemetry_exporter_otlp_proto_grpc-1.41.1-py3-none-any.whl", hash = "sha256:537926dcef951136992479af1d9cd88f25e33d56c530e9f020ed57774dca2f94", size = 20297, upload-time = "2026-04-24T13:15:20.212Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/5b/9d3c7f70cca10136ba82a81e738dee626c8e7fc61c6887ea9a58bf34c606/opentelemetry_exporter_otlp_proto_http-1.41.1.tar.gz", hash = "sha256:4747a9604c8550ab38c6fd6180e2fcb80de3267060bef2c306bad3cb443302bc", size = 24139, upload-time = "2026-04-24T13:15:42.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/4d/ef07ff2fc630849f2080ae0ae73a61f67257905b7ac79066640bfa0c5739/opentelemetry_exporter_otlp_proto_http-1.41.1-py3-none-any.whl", hash = "sha256:1a21e8f49c7a946d935551e90947d6c3eb39236723c6624401da0f33d68edcb4", size = 22673, upload-time = "2026-04-24T13:15:21.313Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/e8/633c6d8a9c8840338b105907e55c32d3da1983abab5e52f899f72a82c3d1/opentelemetry_proto-1.41.1.tar.gz", hash = "sha256:4b9d2eb631237ea43b80e16c073af438554e32bc7e9e3f8ca4a9582f900020e5", size = 45670, upload-time = "2026-04-24T13:15:49.768Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/1e/5cd77035e3e82070e2265a63a760f715aacd3cb16dddc7efee913f297fcc/opentelemetry_proto-1.41.1-py3-none-any.whl", hash = "sha256:0496713b804d127a4147e32849fbaf5683fac8ee98550e8e7679cd706c289720", size = 72076, upload-time = "2026-04-24T13:15:32.542Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d0/54ee30dab82fb0acda23d144502771ff76ef8728459c83c3e89ef9fb1825/opentelemetry_sdk-1.41.1.tar.gz", hash = "sha256:724b615e1215b5aeacda0abb8a6a8922c9a1853068948bd0bd225a56d0c792e6", size = 230180, upload-time = "2026-04-24T13:15:50.991Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/e7/a1420b698aad018e1cf60fdbaaccbe49021fb415e2a0d81c242f4c518f54/opentelemetry_sdk-1.41.1-py3-none-any.whl", hash = "sha256:edee379c126c1bce952b0c812b48fe8ff35b30df0eecf17e98afa4d598b7d85d", size = 180213, upload-time = "2026-04-24T13:15:33.767Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.62b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/de/911ac9e309052aca1b20b2d5549d3db45d1011e1a610e552c6ccdd1b64f8/opentelemetry_semantic_conventions-0.62b1.tar.gz", hash = "sha256:c5cc6e04a7f8c7cdd30be2ed81499fa4e75bfbd52c9cb70d40af1f9cd3619802", size = 145750, upload-time = "2026-04-24T13:15:52.236Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a6/83dc2ab6fa397ee66fba04fe2e74bdf7be3b3870005359ceb7689103c058/opentelemetry_semantic_conventions-0.62b1-py3-none-any.whl", hash = "sha256:cf506938103d331fbb78eded0d9788095f7fd59016f2bda813c3324e5a74a93c", size = 231620, upload-time = "2026-04-24T13:15:35.454Z" }, +] + +[[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, 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, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, + { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, + { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, + { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, + { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, + { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, + { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, +] + +[[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, 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, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[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, 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, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + +[[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, 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, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.49" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" }, + { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" }, + { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" }, + { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" }, + { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" }, + { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" }, + { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[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, 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, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, 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.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, +] From baf515763dd4680c708c665ef41b04274c34d0e9 Mon Sep 17 00:00:00 2001 From: zendaya Date: Fri, 1 May 2026 16:15:07 -0700 Subject: [PATCH 3/6] adds ci fixes --- CHANGELOG.md | 1 + DEVELOPMENT.md | 2 +- tests/conftest.py | 14 ++++++++------ 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88c4ab2..94251d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ This project follows [Semantic Versioning](https://semver.org/). From **v1.0.0** ### Changed +- **`tests/conftest.py`:** create repo **`.tmp/`** at import time so **`pytest --basetemp=.tmp/pytest`** works on fresh checkouts and **Linux** CI (parent dir is no longer Windows-only). - **`pyproject.toml` `[project] name`:** **`flightdeck-ai`** to match the **PyPI** trusted-publisher project; install with **`pip install flightdeck-ai`** / **`uv add flightdeck-ai`** (CLI remains **`flightdeck`**, imports **`flightdeck.*`**). - **Contributor docs** (**`README.md`**, **`DEVELOPMENT.md`**, **`CONTRIBUTING.md`**, **`AGENTS.md`**, **`CLAUDE.md`**, **`.cursorrules`**): prefer **uv**; keep **pip** / **`python -m venv`** as fallback. - **Python:** **`requires-python >=3.14,<3.15`**, **`.python-version`**, PyPI classifiers, **Ruff** `target-version`, **`uv.lock`**, and **CI** matrices now target **CPython 3.14** only (replacing broader **3.11–3.14** testing). diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 434e11c..558fbfe 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -121,7 +121,7 @@ $env:TMPDIR = $env:TEMP uv run python -m pytest ``` -By default, `tests/conftest.py` also redirects `TEMP`/`TMP` into `.tmp/` during pytest on Windows. Set +By default, `tests/conftest.py` creates **`.tmp/`** at import (for **`--basetemp=.tmp/pytest`**) and redirects `TEMP`/`TMP` into that folder during pytest on Windows. Set `FLIGHTDECK_USE_SYSTEM_TEMP=1` if you want to force pytest to use your normal OS temp directory instead. If your shell does not activate virtual environments in the same way as the examples, use the diff --git a/tests/conftest.py b/tests/conftest.py index 3dbb1e6..873f05b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,11 @@ import sys from pathlib import Path +# pyproject.toml sets --basetemp=.tmp/pytest; pytest does not create the parent `.tmp/`. +_REPO_ROOT = Path(__file__).resolve().parents[1] +_REPO_TMP = _REPO_ROOT / ".tmp" +_REPO_TMP.mkdir(parents=True, exist_ok=True) + def pytest_configure() -> None: """ @@ -17,9 +22,6 @@ def pytest_configure() -> None: if sys.platform != "win32": return - tmp_dir = (Path.cwd() / ".tmp").resolve() - tmp_dir.mkdir(parents=True, exist_ok=True) - - os.environ.setdefault("TEMP", str(tmp_dir)) - os.environ.setdefault("TMP", str(tmp_dir)) - os.environ.setdefault("TMPDIR", str(tmp_dir)) + os.environ.setdefault("TEMP", str(_REPO_TMP.resolve())) + os.environ.setdefault("TMP", str(_REPO_TMP.resolve())) + os.environ.setdefault("TMPDIR", str(_REPO_TMP.resolve())) From 295db217c26ddc7db45833e134b9d5e9f7f93361 Mon Sep 17 00:00:00 2001 From: Gottam Sai Bharath <7725109+Gsbreddy@users.noreply.github.com> Date: Fri, 1 May 2026 16:33:28 -0700 Subject: [PATCH 4/6] fix: respect zero policy sample thresholds (#3) Co-authored-by: Cursor Agent Co-authored-by: Gottam Sai Bharath --- src/flightdeck/ledger.py | 8 +++++--- tests/test_ledger.py | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/flightdeck/ledger.py b/src/flightdeck/ledger.py index 5da122a..3b90793 100644 --- a/src/flightdeck/ledger.py +++ b/src/flightdeck/ledger.py @@ -183,9 +183,11 @@ def diff_releases( candidate_rollup = compute_rollup(candidate_events, candidate_pricing_table) # Confidence (policy can override thresholds; otherwise take config defaults) - min_candidate_runs = policy.min_candidate_runs or cfg.diff.min_candidate_runs - min_baseline_runs = policy.min_baseline_runs or cfg.diff.min_baseline_runs - min_low_runs = policy.min_low_runs or cfg.diff.min_low_runs + min_candidate_runs = ( + policy.min_candidate_runs if policy.min_candidate_runs is not None else cfg.diff.min_candidate_runs + ) + min_baseline_runs = policy.min_baseline_runs if policy.min_baseline_runs is not None else cfg.diff.min_baseline_runs + min_low_runs = policy.min_low_runs if policy.min_low_runs is not None else cfg.diff.min_low_runs label = confidence_label( baseline_rollup.runs, diff --git a/tests/test_ledger.py b/tests/test_ledger.py index aad8e8c..270fef5 100644 --- a/tests/test_ledger.py +++ b/tests/test_ledger.py @@ -89,3 +89,27 @@ def test_diff_releases_rejects_mixed_agents_within_side() -> None: candidate_pricing_table=table, window="7d", ) + + +def test_diff_releases_respects_zero_policy_sample_thresholds() -> None: + cfg = WorkspaceConfig() + policy = Policy( + min_baseline_runs=0, + min_candidate_runs=0, + min_low_runs=0, + require_high_diff_confidence=True, + ) + table = _pricing_table() + + result = diff_releases( + cfg=cfg, + policy=policy, + baseline_events=[], + candidate_events=[], + baseline_pricing_table=table, + candidate_pricing_table=table, + window="7d", + ) + + assert result.confidence == "HIGH" + assert result.policy.passed From bbff04151197e0991a8cee80b72aac752aaac348 Mon Sep 17 00:00:00 2001 From: Gottam Sai Bharath <7725109+Gsbreddy@users.noreply.github.com> Date: Fri, 1 May 2026 16:44:06 -0700 Subject: [PATCH 5/6] Fix promotion audit sequencing (#5) Co-authored-by: Cursor Agent Co-authored-by: Gottam Sai Bharath --- src/flightdeck/cli/main.py | 13 ++++++++++-- src/flightdeck/storage.py | 10 +++------- tests/test_doctor.py | 41 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/flightdeck/cli/main.py b/src/flightdeck/cli/main.py index 7dfb301..4f2b23c 100644 --- a/src/flightdeck/cli/main.py +++ b/src/flightdeck/cli/main.py @@ -16,8 +16,17 @@ from flightdeck.config import DEFAULT_CONFIG_FILENAME, load_config, write_default_config from flightdeck.doctor import run_doctor from flightdeck.ledger import diff_releases, parse_window -from flightdeck.models import Policy, PolicyResult, PricingTable, PromotionRecord, ReleaseArtifact, ReleaseRecord, RunEvent -from flightdeck.storage import Storage, utc_now +from flightdeck.models import ( + Policy, + PolicyResult, + PricingTable, + PromotionRecord, + ReleaseArtifact, + ReleaseRecord, + RunEvent, + utc_now, +) +from flightdeck.storage import Storage def read_release_artifact(path: Path) -> ReleaseArtifact: diff --git a/src/flightdeck/storage.py b/src/flightdeck/storage.py index 36b53af..d3bf355 100644 --- a/src/flightdeck/storage.py +++ b/src/flightdeck/storage.py @@ -5,16 +5,12 @@ import sqlite3 from contextlib import contextmanager from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime from pathlib import Path from typing import Any, Iterable from uuid import uuid4 -from flightdeck.models import Policy, PolicyResult, PricingTable, PromotionRecord, ReleaseRecord, RunEvent - - -def utc_now() -> datetime: - return datetime.now(timezone.utc) +from flightdeck.models import Policy, PolicyResult, PricingTable, PromotionRecord, ReleaseRecord, RunEvent, utc_now def ensure_parent_dir(db_path: str) -> None: @@ -506,7 +502,7 @@ def _insert_release_action_conn(conn: sqlite3.Connection, record: PromotionRecor ) def insert_promotion_record(self, record: PromotionRecord) -> None: - with self.connect() as conn: + with self.transaction() as conn: self._insert_release_action_conn(conn, record) def commit_promotion(self, record: PromotionRecord, *, new_promoted_release_id: str) -> None: diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 834e699..84d7fd7 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -7,6 +7,8 @@ from click.testing import CliRunner from flightdeck.cli.main import cli +from flightdeck.models import PolicyResult, PromotionRecord +from flightdeck.storage import Storage from tests.test_spine import write_events, write_policy, write_pricing, write_release @@ -107,6 +109,45 @@ def test_doctor_fails_on_audit_seq_gap(tmp_path: Path, monkeypatch) -> None: assert "audit_seq" in res.output.lower() +def test_insert_promotion_record_uses_immediate_transaction(tmp_path: Path) -> None: + storage = Storage(str(tmp_path / "flightdeck.db")) + storage.migrate() + with storage.connect() as conn: + conn.execute( + """ + INSERT INTO releases + (release_id, agent_id, version, environment, checksum, artifact_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ("rel_1", "agent_support", "1", "local", "sha256:abc", "{}", "2026-05-01T00:00:00+00:00"), + ) + + record = PromotionRecord( + action_id="act_1", + action="promote", + actor="tester", + release_id="rel_1", + agent_id="agent_support", + environment="local", + reason="test", + policy_result=PolicyResult(passed=True), + created_at=datetime.now(tz=timezone.utc), + ) + + competing_conn = storage.connect() + try: + competing_conn.execute("BEGIN IMMEDIATE;") + try: + storage.insert_promotion_record(record) + except sqlite3.OperationalError as exc: + assert "database is locked" in str(exc) + else: + raise AssertionError("insert_promotion_record did not request an immediate write lock") + finally: + competing_conn.rollback() + competing_conn.close() + + def test_doctor_fails_when_promoted_release_missing(tmp_path: Path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) runner = CliRunner() From 9c571ff1e070fd46ce1aa7cf3c4108deda9e56b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 1 May 2026 23:46:30 +0000 Subject: [PATCH 6/6] docs: document PR #5 audit transaction fix and utc_now relocation - CHANGELOG: add entries for insert_promotion_record BEGIN IMMEDIATE fix, utc_now move to flightdeck.models, and new write-lock test - RELEASE_NOTES: add 'Promotion audit write-lock guarantee' section explaining the BEGIN IMMEDIATE serialization contract, busy-timeout behaviour, and the new canonical location of utc_now() Co-authored-by: Gottam Sai Bharath --- CHANGELOG.md | 3 +++ RELEASE_NOTES.md | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94251d2..29bc773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ This project follows [Semantic Versioning](https://semver.org/). From **v1.0.0** - **CI:** **`astral-sh/setup-uv`** with **`uv sync --frozen --extra dev`** and **`uv run python -m …`** (avoids **`uv run pytest`** path quirks with **`from tests.…`** imports). - **`.github/workflows/release-pypi.yml`:** on push of **`vMAJOR.MINOR.PATCH`**, verify tag matches **`pyproject.toml`** and **`src/flightdeck/__init__.py`**, run **ruff** / **pytest** / schema drift, **`uv build`**, publish to **PyPI** via **OIDC** trusted publishing (**publish attestations**), and create a **GitHub Release** with **`dist/*`** assets (**`softprops/action-gh-release`**). - **`tests/test_version_consistency.py`:** assert **`pyproject.toml`** **`version`** matches **`flightdeck.__version__`** (same invariant as the release workflow). +- **`Storage.insert_promotion_record` now uses `BEGIN IMMEDIATE` transaction:** the promotion audit-record insert now calls `self.transaction()` (which issues `BEGIN IMMEDIATE`) instead of `self.connect()`. This serializes write access and prevents concurrent writers from racing on `audit_seq` assignment. `commit_promotion` already used `transaction()`; this change closes the analogous gap for standalone promotion record inserts. +- **`utc_now` moved to `flightdeck.models`:** the UTC timestamp helper is now defined in `flightdeck.models` and imported from there by both `flightdeck.storage` and `flightdeck.cli.main`. Previously it lived in `flightdeck.storage`. +- **Test for promotion write-lock (`test_insert_promotion_record_uses_immediate_transaction`):** verifies that `insert_promotion_record` holds a SQLite write lock (i.e., a competing `BEGIN IMMEDIATE` sees `database is locked`) to guard the append-only `audit_seq` ledger invariant. ### Changed diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index bbe3a1d..ec1c06c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -44,6 +44,20 @@ Optional OTEL libraries (not used by core today): **`pip install 'flightdeck-ai[ Schema evolves via **numbered migrations**. Existing **`flightdeck.yaml`** / **`.flightdeck/`** trees pick up new migrations on next CLI or **`serve`** startup when **`Storage.migrate()`** runs. If you maintain long-lived local databases, skim **[CHANGELOG.md](CHANGELOG.md)** before upgrading across **minor** versions. +## Promotion audit write-lock guarantee + +`Storage.insert_promotion_record()` and `Storage.commit_promotion()` both use `BEGIN IMMEDIATE` transactions (via `Storage.transaction()`). This ensures: + +- The `audit_seq` counter read-then-write inside `_insert_release_action_conn` is serialized — no two concurrent writers can claim the same sequence number. +- `commit_promotion` atomically writes both the audit record and the `promoted_releases` pointer in one `BEGIN IMMEDIATE` transaction. +- `flightdeck doctor` can rely on `audit_seq` being contiguous (`1..max`) as a tamper/partial-write indicator, because every successful insert holds the exclusive write lock for the full sequence-number assignment. + +If you call `Storage.insert_promotion_record` or `Storage.commit_promotion` while another connection holds a write lock, you will see `sqlite3.OperationalError: database is locked` (busy timeout: 5 s via `PRAGMA busy_timeout=5000`). Callers should treat this as a transient error and retry. + +### `utc_now` location + +The `utc_now()` helper (`datetime.now(timezone.utc)`) is defined in **`flightdeck.models`**. Import it from there; **`flightdeck.storage`** no longer re-exports it. + ## Semantic versioning (from v1.0.0) **Patch** — bug fixes and internal refactors that preserve CLI/schema/HTTP contracts.