diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..c09eb0e --- /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 (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/.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..b603b62 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.14"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + + - name: Sync dependencies + run: uv sync --frozen --extra dev + + - 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: Quickstart smoke (cross-platform) + run: uv run python scripts/quickstart_smoke.py + + - name: CLI smoke + run: uv run flightdeck --help + + test-windows: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.14"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + + - name: Sync dependencies + run: uv sync --frozen --extra dev + + - 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: Quickstart smoke (cross-platform) + run: uv run python scripts/quickstart_smoke.py + + - name: CLI smoke + 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/.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/.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 new file mode 100644 index 0000000..69d6788 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,104 @@ +# 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 + +Recommended (**[uv](https://docs.astral.sh/uv/)** — see **`DEVELOPMENT.md`**): + +```bash +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** (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. + +## 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..cf4d0d8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,215 @@ +# 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 + +### 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). + +### Fixed + +- **`diff_releases` zero policy sample thresholds:** `Policy.min_candidate_runs`, `Policy.min_baseline_runs`, and `Policy.min_low_runs` set to **`0`** now correctly override workspace config defaults to `0` instead of being silently ignored. Previously, `or`-based fallback treated `0` as falsy and fell back to the config value (typically `500` / `50`). Fixed by using explicit `is not None` checks. A policy can now unconditionally accept any sample size by setting thresholds to `0` — for example, to allow diffs over empty event windows without a confidence downgrade. + +### 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). + +## 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 (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**. + +### 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..d95a696 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,38 @@ +# 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 + +With **uv** (recommended): + +```bash +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 +``` + +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 **`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 new file mode 100644 index 0000000..aa1a394 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,77 @@ +# 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 + +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]" +``` + +## 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 +``` + +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 + +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..558fbfe --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,134 @@ +# Development + +## Requirements + +- **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. + +**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 +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). + +**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 +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 +uv run python -m pytest +``` + +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 +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/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..ef6f50c --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# 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 **`uv sync --extra telemetry`** or **`pip install 'flightdeck-ai[telemetry]'`** for future work) + +Shipped locally: + +- `flightdeck serve` + `POST /v1/events` +- minimal Python SDK (`flightdeck.sdk.client`) +- `flightdeck release rollback` (policy-gated, audited) + +## 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]" +flightdeck --help +``` + +Run the cross-platform quickstart smoke (same as CI): + +```bash +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 +./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 **`uv run python scripts/quickstart_smoke.py`** / **`python scripts/quickstart_smoke.py`** (venv) 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 +uv sync --frozen --extra dev +uv run python -m ruff check src tests +uv run python -m pytest +``` + +See [DEVELOPMENT.md](DEVELOPMENT.md) for **uv** and **pip** setup, verification, troubleshooting, and **PyPI releases** (tag-driven; not on merge to `main`). + +## 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..bbe3a1d --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,58 @@ +# 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 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 + +**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-ai[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..51f40c3 --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,38 @@ +# 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)**. + +## 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/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..e3d30a6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,77 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "flightdeck-ai" +version = "1.0.1" +description = "AI Release Governance for production agents." +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.14,<3.15" +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.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.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 = "py314" +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..33df8be --- /dev/null +++ b/schemas/v1/policy.schema.json @@ -0,0 +1,96 @@ +{ + "description": "Promotion-gate policy for a release diff.\n\n**Constraint fields** (``max_*``) \u2014 when ``None`` the constraint is\ndisabled. When set, the candidate rollup must not exceed the limit for the\npolicy to pass.\n\n**Sample threshold fields** (``min_*``) \u2014 control the confidence label\nassigned by ``diff_releases``:\n\n- ``None`` (default) \u2014 defer to ``WorkspaceConfig.diff`` defaults\n (typically ``min_candidate_runs=500``, ``min_baseline_runs=500``,\n ``min_low_runs=50``).\n- ``0`` \u2014 unconditionally accept any sample size for that threshold,\n including an empty event list. All three set to ``0`` means any diff\n window, even an empty one, can reach HIGH confidence.\n\nThe ``None`` / ``0`` distinction is intentional: ``None`` means \"inherit\nfrom config\", not \"zero runs required\". ``diff_releases`` uses\n``is not None`` checks to respect an explicit ``0`` override.", + "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..4f2b23c --- /dev/null +++ b/src/flightdeck/cli/main.py @@ -0,0 +1,727 @@ +"""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, + utc_now, +) +from flightdeck.storage import Storage + + +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..4ed0874 --- /dev/null +++ b/src/flightdeck/ledger.py @@ -0,0 +1,310 @@ +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"]: + """Return a three-tier confidence label for a diff comparison. + + Thresholds come from the resolved policy (which can set them to 0 to + unconditionally accept any sample size): + + - HIGH — both sides meet their minimum run counts. + - LOW — either side falls below the floor (``min_low_runs``). + - MEDIUM — in between: at least one side misses its target but neither + is below the floor. + + A threshold of 0 is valid and means "no minimum required"; for example, + setting all three to 0 lets an empty-window diff still return HIGH. + """ + 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: + """Evaluate promotion-gate policy against a computed diff. + + Each active constraint appends a human-readable failure reason; an empty + reasons list means the policy passed. + + Constraints checked (only when the corresponding policy field is not None): + + - ``max_cost_per_run_usd`` — candidate average cost must not exceed limit. + - ``max_latency_ms`` — candidate average latency must not exceed limit + (skipped when candidate has no latency data). + - ``max_error_rate`` — candidate error rate must not exceed limit. + - ``require_high_diff_confidence`` — when ``True``, the diff must reach + HIGH confidence (based on sample thresholds) before promotion is allowed. + """ + 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: + """Compute a trusted diff between a baseline and candidate release. + + Each side is costed independently against its own pricing table, then + rolled up into a :class:`Rollup` (cost, latency, error rate). Confidence + is determined by sample size relative to the resolved thresholds, and + policy constraints are evaluated on top. + + **Threshold resolution** — policy fields use ``is not None`` to distinguish + "not set" from "explicitly zero": + + - If ``Policy.min_candidate_runs`` (/ ``min_baseline_runs`` / ``min_low_runs``) + is ``None``, the workspace config default is used (typically 500 / 500 / 50). + - If the policy sets a threshold to ``0``, that override is respected and + the config default is *not* used — any sample size satisfies the threshold. + + **Agent-id invariant** — when both sides are non-empty, every run event on + each side must share the same ``agent_id``, and baseline and candidate must + use the same ``agent_id``. Cross-agent diffs are rejected with + ``ValueError``. + + Args: + cfg: Workspace configuration supplying diff defaults. + policy: Active promotion policy (thresholds and constraints). + baseline_events: Run events for the baseline release. + candidate_events: Run events for the candidate release. + baseline_pricing_table: Pricing used to cost baseline events. + candidate_pricing_table: Pricing used to cost candidate events. + window: Human-readable label for the time window (e.g. ``"7d"``); + stored on the result for display only — filtering is the caller's + responsibility. + + Returns: + A :class:`DiffResult` containing rollups, deltas, confidence label, + and the policy evaluation outcome. + + Raises: + ValueError: If events span multiple agent IDs on one side, or if + baseline and candidate use different agent IDs. + """ + 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 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, + 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..37af999 --- /dev/null +++ b/src/flightdeck/models.py @@ -0,0 +1,235 @@ +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): + """Promotion-gate policy for a release diff. + + **Constraint fields** (``max_*``) — when ``None`` the constraint is + disabled. When set, the candidate rollup must not exceed the limit for the + policy to pass. + + **Sample threshold fields** (``min_*``) — control the confidence label + assigned by ``diff_releases``: + + - ``None`` (default) — defer to ``WorkspaceConfig.diff`` defaults + (typically ``min_candidate_runs=500``, ``min_baseline_runs=500``, + ``min_low_runs=50``). + - ``0`` — unconditionally accept any sample size for that threshold, + including an empty event list. All three set to ``0`` means any diff + window, even an empty one, can reach HIGH confidence. + + The ``None`` / ``0`` distinction is intentional: ``None`` means "inherit + from config", not "zero runs required". ``diff_releases`` uses + ``is not None`` checks to respect an explicit ``0`` override. + """ + + 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..d3bf355 --- /dev/null +++ b/src/flightdeck/storage.py @@ -0,0 +1,628 @@ +from __future__ import annotations + +import hashlib +import json +import sqlite3 +from contextlib import contextmanager +from dataclasses import dataclass +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, utc_now + + +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.transaction() 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..873f05b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import os +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: + """ + 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 + + os.environ.setdefault("TEMP", str(_REPO_TMP.resolve())) + os.environ.setdefault("TMP", str(_REPO_TMP.resolve())) + os.environ.setdefault("TMPDIR", str(_REPO_TMP.resolve())) 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..84d7fd7 --- /dev/null +++ b/tests/test_doctor.py @@ -0,0 +1,172 @@ +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 flightdeck.models import PolicyResult, PromotionRecord +from flightdeck.storage import Storage + +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_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() + 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..270fef5 --- /dev/null +++ b/tests/test_ledger.py @@ -0,0 +1,115 @@ +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", + ) + + +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 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 + 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" }, +]